Compare commits

..
34 Commits
Author SHA1 Message Date
fatedierandGitHub f13fdd58d4 feat: add metricsServer for health checks, metrics and pprof (#5578)
Add a separate HTTP listener for observability, configured through
metricsServer in both frps and frpc. It serves /healthz and, when
pprofEnable is set, the pprof handlers except /debug/pprof/cmdline. It
never serves the dashboard, static assets or the admin API, and it does
not touch the global assets filesystem.

frps also serves /metrics on this listener with both runtime and business
metrics, enabled by metricsServer.port alone. The in-memory collector
still starts only with the dashboard, and the Prometheus collector starts
either with metricsServer or with enablePrometheus plus webServer, so a
configuration that collected nothing before still collects nothing.

The /healthz, /metrics and pprof endpoints on webServer, and
enablePrometheus, keep working but are deprecated and log a hint at
startup. frpc does not serve /metrics yet.
2026-10-19 20:08:39 +08:00
fatedierandGitHub 00e4fc4dd3 feat(client): reload plugin certificates on frpc reload (#5576)
The https2http and https2https plugins read their certificate and key
once when the proxy starts, so a certificate that an external tool renews
on disk is only picked up after the proxy itself is recreated. frpc
reload rebuilt proxies only when their configuration changed, and a
rebuild closes the work connections that are already in use.

Let the plugin own the refresh. The HTTPS plugin server keeps the active
keypair and the last refresh error in one immutable snapshot published
through an atomic pointer, so a TLS handshake only loads that pointer and
never touches the disk. Reload rereads both files, and when their bytes
match the published pair it returns without parsing or replacing
anything; otherwise it parses the pair and swaps the snapshot in. A read
or parse failure keeps the previous certificate serving and records the
error next to it.

frpc reload collects the reloadable plugins of the live proxies and
applies the new configuration inside the same reload transaction that
also serializes control handoff, so a reload that races a reconnect
still refreshes the plugins of the published control. The certificate
files are read only after that transaction has been released. The config
store API keeps applying configuration without refreshing certificates,
because only an explicit reload may reread files. Proxy status reports a
refresh failure next to the still running proxy instead of dropping its
remote address.

No configuration field is added, and frps and the wire protocol are
unchanged. Without a reload nothing changes: certificates are still read
once at startup, generated self-signed certificates still never reload,
and a proxy whose configuration changed still goes through the existing
rebuild path.
2026-10-19 17:16:55 +08:00
fatedierandGitHub 585fdc5b83 fix(client): serialize config updates with control handoff (#5577) 2026-10-19 16:07:07 +08:00
fatedierandGitHub 228d23b3d5 fix: release pending kcp sessions when the listener is closed (#5575)
KCPListener hands accepted sessions to a consumer through an unbuffered
channel, but its accept goroutine wrote to that channel without watching for
shutdown. A Close racing with a handoff left the goroutine blocked forever,
holding the session it was about to deliver, because closing the underlying
listener does not unblock a send. The close flag it read was also written by
Close without synchronisation.

Watch a done channel in the handoff and close the connection that was never
delivered, so the goroutine can exit and the session is released. Accept now
returns ErrKCPListenerClosed as soon as the listener is closed instead of
blocking on the channel, and the close state is a done channel guarded by
sync.Once.

A ready handoff can still win the race with Close; the session then belongs to
the caller of Accept, matching how WebsocketListener behaves.

Tests cover a pending handoff that is cancelled by Close and a blocked Accept
that is woken by it.
2026-10-19 13:20:00 +08:00
fatedierandGitHub 399bf2ce4c fix: release listeners when frps fails to start (#5574)
A constructor that binds a port before its fallible preparation leaks the
listener when a later step returns an error, and no one can close it because
the returned service is nil.

pkg/util/http: load the TLS keypair and build the auth middleware before
net.Listen, so binding is the last step that can run and a failure leaves no
bound port behind.

server: create the web server after auth, the tcpmux, main, kcp, quic, ssh
gateway and vhost listeners, and the nat hole controller, following what frpc
already does. If the web server itself fails, close the resources created so
far before returning. The port managers start their cleanup workers on
creation, so they are created after every fallible step too, and the metrics
collectors keep being enabled only when the web server is configured.

Add regression tests for all three failure paths.
2026-10-19 12:20:25 +08:00
fatedierandGitHub 26742060b1 feat(client): add useSystemCa and combine it with trustedCaFile (#5572)
Move the frpc-only TLS option into the shared TLSConfig as
transport.tls.useSystemCa, replacing the unpublished useSystemRoots.
When set, frpc verifies the server with the system CAs; when
trustedCaFile is also set, its CAs are appended to the system pool so
both are trusted. Failures to load either source abort the connection
instead of falling back. Enabling the option without effective TLS is a
configuration error, and the field is rejected in frps transport.tls
and in both webServer.tls sections.

Fixes #5545
2026-10-18 16:00:02 +08:00
Alexey SviridovandGitHub 7441443134 feat(client): verify server certificates against the system root store (#5546)
transport.tls.trustedCaFile is installed as RootCAs, and omitting it sets
InsecureSkipVerify, so system-root verification cannot be selected directly.
Add an opt-in transport.tls.useSystemRoots that enables certificate
verification with RootCAs nil when no trusted CA file is set, allowing Go to
use system roots and native verification on macOS and Windows. Defaults are
unchanged.
2026-10-18 14:08:05 +08:00
fatedierandGitHub 954f918d0c docs: clear release notes (#5570) 2026-10-15 23:25:45 +08:00
fatedierandGitHub 031716afd1 Merge pull request #5569 from fatedier/fix/docker-workspace-lockfile
fix(docker): install web dependencies from the workspace lockfile
2026-10-15 13:01:59 +08:00
fatedierandGitHub 41ed2aecf8 test: align UDP compatibility protocol boundaries (#5567) 2026-10-14 10:49:55 +08:00
fatedierandGitHub 12b0093ccf chore: prepare v0.71.1 release notes (#5564) 2026-10-13 10:46:29 +08:00
fatedierandGitHub 2542d11a62 fix(net): coordinate websocket handoff with listener shutdown (#5563) 2026-10-10 19:58:05 +08:00
Abdellatif AnaflousandGitHub c4d2db4177 fix(websocket): release a pending Accept when the listener is closed (#5552)
the websocket listener kept its accept channel open on Close, so an Accept call that was already waiting blocked forever

every other listener in pkg/util/net closes the channel and returns an error, but this one never did, so the HandleListener goroutine stayed stuck after the server closed

i close the channel on Close, guard the hand off send so a late connection does not panic, and return ErrWebsocketListenerClosed which was declared but never reached

Signed-off-by: Abdel <62770500+hktitof@users.noreply.github.com>
2026-10-10 18:19:59 +08:00
Mxmilu666andGitHub 71b31978b3 fix(server): close underlying work conn when server bandwidth limiter is enabled (#5553)
When bandwidthLimitMode is "server", the work connection is wrapped by
libio.WrapReadWriteCloser with a close callback that captured the
variable being reassigned (local/rwc). At call time the callback
referenced the wrapper itself, whose closed guard was already set, so
Close returned nil without closing the underlying work connection.

For TCP proxies this left libio.Join blocked on the other copy
goroutine after the user disconnected, keeping the work connection and
the backend connection open. HTTP and UDP proxies had the same pattern.

Bind the Close method value before reassignment, and add an e2e test
verifying that the local connection is closed after the user
disconnects.
2026-10-10 10:45:34 +08:00
fatedierandGitHub 1cc2dd8a23 fix(vhost): avoid request body access after CONNECT hijack (#5562) 2026-10-09 23:53:05 +08:00
dhairyaandGitHub 9c7d9898b3 fix(vhost): preserve buffered HTTP CONNECT tunnel data (#5556)
* fix(vhost): preserve buffered HTTP CONNECT tunnel data

* test(vhost): cover pipelined CONNECT data and bidirectional tunnel traffic
2026-10-09 23:07:48 +08:00
dhairyaandGitHub ab0959868a fix: rotate daily counters by calendar date across DST (#5555)
* Fix daily counter rotation across daylight-saving changes

* Test daily counter rotation across DST and skipped midnight
2026-10-09 20:13:03 +08:00
fatedierandGitHub 1dbd44edd3 fix(msg): interrupt control reads during dispatcher shutdown (#5561) 2026-10-09 17:46:31 +08:00
CyoidandGitHub fccc7b0e9f fix(msg): close control connection on write failure (#5550)
* fix(msg): close control connection on write failure

* fix(msg): handle shutdown enqueue race and preserve constructor API
2026-10-09 16:17:08 +08:00
fatedierandGitHub 55107d003d fix: synchronize service startup and shutdown (#5560) 2026-10-09 12:27:12 +08:00
fatedierandGitHub 4cd4747525 fix: use Go 1.26 new expressions for pointer initialization (#5559) 2026-10-08 22:28:31 +08:00
f8be1cdfe2 deps: bump golang.org/x/crypto to v0.56.0 and build with Go 1.26 (#5558)
x/crypto v0.56.0 fixes two denial-of-service bugs in golang.org/x/crypto/ssh
channel handling (GO-2026-6354 / CVE-2026-78662, GO-2026-6355 /
CVE-2026-56855), reachable through the frps SSH tunnel gateway in pkg/ssh.
v0.55.0 fixes GO-2026-6303 / CVE-2026-56854. v0.56.0 requires Go 1.26, so the
go directive, CI and Docker builders move from 1.25 to 1.26; Go 1.25 stopped
receiving security fixes when Go 1.27 was released.

Also bumps github.com/Azure/go-ntlmssp to v0.1.1 (GO-2026-5543 /
CVE-2026-32952).


Claude-Session: https://claude.ai/code/session_01FV4GkYDtbatepp2KH3s247

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:06:35 +08:00
SebastianandGitHub d5636108e6 [fix] race condition when trying to reconnect (#5547) 2026-10-08 21:22:43 +08:00
fatedierandGitHub d20a232996 test: strengthen health check coverage (#5528) 2026-09-10 01:28:26 +08:00
fatedierandGitHub b0d840858f ci: upgrade CircleCI config to 2.1 (#5530) 2026-09-10 00:17:12 +08:00
fatedierandGitHub c5508627c4 docs: add rapidproxy as gold sponsor (#5529) 2026-09-10 00:02:51 +08:00
Rui JingAnandGitHub 832df8dff6 fix(health): reset failed count after successful check (#5502)
Signed-off-by: racequite <quiterace@gmail.com>
2026-08-29 22:55:32 +08:00
fatedierandGitHub ebb4773c2c docs: update release notes (#5521) 2026-08-29 14:41:43 +08:00
fatedierandGitHub 23ec9b4979 vnet: serialize VirtualNet route lifecycle (#5512)
* vnet: serialize virtual net route lifecycle

* test: remove fixed virtual net helper parameters
2026-08-28 10:56:05 +08:00
flyingandGitHub 40bb45d192 vnet: fix route cleanup and reconnect backoff (#5492) 2026-08-26 23:33:45 +08:00
fatedierandGitHub 758f07d59e deps: migrate to fatedier/yamux v0.2.0 (#5498) 2026-08-18 00:24:11 +08:00
fatedierandGitHub 25a4ba306a ci: pin golangci-lint to v2.12.2 (#5496) 2026-08-17 00:48:09 +08:00
fatedierandGitHub 6c8a8d0a97 web: patch vulnerable transitive dependencies (#5490) 2026-08-13 01:12:34 +08:00
fatedierandGitHub da04e1e07a log: improve prefix handling (#5489) 2026-08-12 23:56:00 +08:00
131 changed files with 6653 additions and 2680 deletions
+5 -4
View File
@@ -1,8 +1,8 @@
version: 2
version: 2.1
jobs:
go-version-latest:
docker:
- image: cimg/go:1.25-node
- image: cimg/go:1.26-node
resource_class: large
steps:
- checkout
@@ -16,10 +16,11 @@ jobs:
make env fmt
git diff --exit-code
make build
- run: make alltest
- run:
name: Run all tests
command: make alltest
workflows:
version: 2
build_and_test:
jobs:
- go-version-latest
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [fatedier]
custom: ["https://afdian.com/a/fatedier"]
+3
View File
@@ -0,0 +1,3 @@
### WHY
<!-- author to complete -->
-194
View File
@@ -1,194 +0,0 @@
name: Build FRP Binaries
on:
push:
branches:
- '**'
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin, freebsd, openbsd, android]
goarch: [amd64, 386, arm, arm64]
include:
- goos: linux
goarch: loong64
exclude:
- goos: darwin
goarch: arm
- goos: darwin
goarch: 386
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: android
goarch: amd64
- goos: android
goarch: 386
# 排除 Android ARM 32位,在单独的 job 中处理
- goos: android
goarch: arm
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y zip tar make gcc g++ upx
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
run: |
mkdir -p release/packages
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
# 构建版本号
make
version=$(./bin/frps --version)
echo "Detected version: $version"
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
export CGO_ENABLED=0
# 构建可执行文件
make frpc frps
if [ "${{ matrix.goos }}" = "windows" ]; then
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
fi
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
mkdir -p "$out_dir"
if [ "${{ matrix.goos }}" = "windows" ]; then
mv ./bin/frpc.exe "$out_dir/frpc.exe"
mv ./bin/frps.exe "$out_dir/frps.exe"
else
mv ./bin/frpc "$out_dir/frpc"
mv ./bin/frps "$out_dir/frps"
fi
cp LICENSE "$out_dir"
cp -f conf/frpc.toml "$out_dir"
cp -f conf/frps.toml "$out_dir"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
path: |
release/packages/frp_*
retention-days: 7
build-android-arm:
name: Build FRP android-arm
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies and Android NDK
run: |
sudo apt-get update -y
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
# 下载并安装 Android NDK
echo "Downloading Android NDK..."
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
echo "Extracting Android NDK..."
unzip -q android-ndk-r26c-linux.zip
echo "NDK installed at: $PWD/android-ndk-r26c"
- name: Build FRP for android-arm
run: |
mkdir -p release/packages
mkdir -p bin
echo "Building for android-arm with CGO"
# 首先构建一次获取版本号
CGO_ENABLED=0 make
version=$(./bin/frps --version)
echo "Detected version: $version"
# 清理之前的构建
rm -rf ./bin/*
# 设置 Android ARM 交叉编译环境
export GOOS=android
export GOARCH=arm
export GOARM=7
export CGO_ENABLED=1
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
echo "Environment:"
echo "GOOS=$GOOS"
echo "GOARCH=$GOARCH"
echo "GOARM=$GOARM"
echo "CGO_ENABLED=$CGO_ENABLED"
echo "CC=$CC"
# 直接使用 go build 命令,不通过 Makefile,防止 CGO_ENABLED 被覆盖
# -checklinkname=0: 关闭 Go 1.23+ 的 linkname 检查,规避 wlynxg/anet
# 在 android 下通过 //go:linkname 引用 net.zoneCache 导致的链接失败
echo "Building frps..."
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frps -o bin/frps ./cmd/frps
echo "Building frpc..."
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frpc -o bin/frpc ./cmd/frpc
# 验证文件已生成
ls -lh ./bin/
file ./bin/frpc
file ./bin/frps
out_dir="release/packages/frp_${version}_android_arm"
mkdir -p "$out_dir"
mv ./bin/frpc "$out_dir/frpc"
mv ./bin/frps "$out_dir/frps"
cp LICENSE "$out_dir"
cp -f conf/frpc.toml "$out_dir"
cp -f conf/frps.toml "$out_dir"
echo "Build completed for android-arm"
ls -lh "$out_dir"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: LoliaFrp_android_arm
path: |
release/packages/frp_*
retention-days: 7
@@ -0,0 +1,83 @@
name: Build Image and Publish to Dockerhub & GPR
on:
release:
types: [ published ]
workflow_dispatch:
inputs:
tag:
description: 'Image tag'
required: true
default: 'test'
permissions:
contents: read
jobs:
image:
name: Build Image from Dockerfile and binaries
runs-on: ubuntu-latest
steps:
# environment
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: '0'
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# get image tag name
- name: Get Image Tag Name
run: |
if [ x${{ github.event.inputs.tag }} == x"" ]; then
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
else
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
fi
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to the GPR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GPR_TOKEN }}
# prepare image tags
- name: Prepare Image Tags
run: |
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
- name: Build and push frpc
uses: docker/build-push-action@v7
with:
context: .
file: ./dockerfiles/Dockerfile-for-frpc
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
push: true
tags: |
${{ env.TAG_FRPC }}
${{ env.TAG_FRPC_GPR }}
- name: Build and push frps
uses: docker/build-push-action@v7
with:
context: .
file: ./dockerfiles/Dockerfile-for-frps
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
push: true
tags: |
${{ env.TAG_FRPS }}
${{ env.TAG_FRPS_GPR }}
-82
View File
@@ -1,82 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- dev
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
include:
- name: frps
dockerfile: dockerfiles/Dockerfile-for-frps
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
- name: frpc
dockerfile: dockerfiles/Dockerfile-for-frpc
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image_name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Generate image digest
if: github.event_name != 'pull_request'
run: |
echo "Image pushed to: ${{ matrix.image_name }}"
echo "Tags: ${{ steps.meta.outputs.tags }}"
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: '1.26'
cache: false
- uses: actions/setup-node@v6
with:
@@ -28,4 +28,4 @@ jobs:
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.11
version: v2.12.2
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: '1.26'
- uses: actions/setup-node@v6
with:
node-version: '22'
-129
View File
@@ -1,129 +0,0 @@
name: Release FRP Binaries
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to release (e.g., v1.0.0)"
required: true
type: string
permissions:
contents: write
jobs:
# 调用 build-all workflow
build:
uses: ./.github/workflows/build-all.yaml
permissions:
contents: read
# 创建 release
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get tag name
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display artifact structure
run: |
echo "Artifact structure:"
ls -R artifacts/
- name: Organize release files
run: |
mkdir -p release_files
# 查找并复制所有压缩包
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
# 如果没有压缩包,尝试查找二进制文件并打包
if [ -z "$(ls -A release_files/)" ]; then
echo "No archives found, looking for directories to package..."
for dir in artifacts/*/; do
if [ -d "$dir" ]; then
artifact_name=$(basename "$dir")
echo "Packaging $artifact_name"
# 检查是否是 Windows 构建
if echo "$artifact_name" | grep -q "windows"; then
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
else
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
fi
fi
done
fi
echo "Files in release_files:"
ls -lh release_files/
- name: Generate checksums
run: |
cd release_files
if [ -n "$(ls -A .)" ]; then
sha256sum * > sha256sum.txt
cat sha256sum.txt
else
echo "No files to generate checksums for!"
exit 1
fi
- name: Debug - Check tags and commits
run: |
echo "Current tag: ${{ steps.tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
echo "Previous tag: $PREV_TAG"
echo ""
echo "Commits between tags:"
if [ "$PREV_TAG" != "none" ]; then
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
else
echo "First tag, showing all commits:"
git log --oneline ${{ steps.tag.outputs.tag }}
fi
- name: Build Changelog
id: changelog
uses: requarks/changelog-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.tag.outputs.tag }}
writeToFile: false
includeInvalidCommits: true
useGitmojis: false
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Release ${{ steps.tag.outputs.tag }}
body: ${{ steps.changelog.outputs.changes }}
draft: false
prerelease: false
files: |
release_files/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-4
View File
@@ -32,10 +32,6 @@ node_modules/
*.swp
# AI
CLAUDE.md
.claude/
.sisyphus/
.superpowers/
# TLS
.autotls-cache
+1 -3
View File
@@ -1,8 +1,6 @@
export PATH := $(PATH):`go env GOPATH`/bin
export GO111MODULE=on
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep),
# which uses //go:linkname to reference net.zoneCache on android targets.
LDFLAGS := -s -w -checklinkname=0
LDFLAGS := -s -w
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
+45 -13
View File
@@ -12,16 +12,6 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<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">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
@@ -39,6 +29,17 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<b>The complete IDE crafted for professional Go developers</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">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end-->
## What is frp?
@@ -615,13 +616,31 @@ Proxies and visitors managed through the Store are saved to disk and automatical
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
Prometheus is also supported.
Both frps and frpc support a separate `metricsServer` listener for health checks and optional profiling, without enabling the dashboard or admin API. It is disabled by default (`metricsServer.port = 0`) and binds to `127.0.0.1` unless configured otherwise.
`/healthz` returns an empty HTTP 200 response without authentication. It checks that the HTTP server is responding; it does not indicate whether frpc is connected to frps or whether proxies are ready.
#### Prometheus
Enable dashboard first, then configure `enablePrometheus = true` in `frps.toml`.
Configure a dedicated listener in `frps.toml`:
`http://{dashboard_addr}/metrics` will provide prometheus monitor data.
```toml
metricsServer.addr = "127.0.0.1"
metricsServer.port = 7501
metricsServer.user = "monitor"
metricsServer.password = "change-me"
# Optional TLS, independent of webServer:
# metricsServer.tls.certFile = "metrics.crt"
# metricsServer.tls.keyFile = "metrics.key"
# Profiling is disabled unless explicitly enabled:
# metricsServer.pprofEnable = true
```
Scrape `http://127.0.0.1:7501/metrics` using these Basic Auth credentials. This automatically enables frps business metrics, even when `webServer` is disabled and `enablePrometheus` is unset. Credentials and TLS are independent of `webServer`; when credentials are configured, both `/metrics` and profiling endpoints require authentication. `/healthz` remains anonymous.
Set `metricsServer.pprofEnable = true` to expose `/debug/pprof/` and profiling endpoints on this listener. `/debug/pprof/cmdline` is excluded. frpc supports the same health and profiling settings, but does not provide `/metrics` yet.
The `/healthz`, `/metrics`, and pprof endpoints on `webServer` are deprecated but remain available. `enablePrometheus = true` still enables the legacy frps `/metrics` endpoint on the dashboard listener. Migrate health probes, Prometheus scrapes, and profiling to `metricsServer`; the dashboard and admin APIs continue to use `webServer`.
### Authenticating the Client
@@ -711,6 +730,10 @@ transport.tls.keyFile = "certificate.key"
transport.tls.trustedCaFile = "ca.crt"
```
By default, when neither `transport.tls.trustedCaFile` nor `transport.tls.useSystemCa` is set, `frpc` does not verify the server certificate. Set `transport.tls.useSystemCa = true` to verify it using the host's system CAs. If `trustedCaFile` is also set, its CA certificates are added to the system CA pool; setting only `trustedCaFile` trusts only the CAs in that file. CA loading or certificate verification failures cause the connection to fail without falling back to another trust source or skipping verification.
`useSystemCa` is supported only in `frpc`'s `transport.tls`, and requires `transport.tls.enable = true` unless `transport.protocol = "wss"`. With `wss`, the outer TLS connection is always enabled and verification applies to the TLS endpoint, which may be a reverse proxy. Setting `useSystemCa = true` in `frps`'s `transport.tls` or either process's `webServer.tls` or `metricsServer.tls` is a configuration error.
**`frps` TLS settings:**
```toml
@@ -804,6 +827,15 @@ webServer.port = 7400
Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies.
For unchanged proxies using `https2http` or `https2https`, `frpc reload` also rereads
certificate and key files so externally renewed certificates take effect on new
TLS handshakes without interrupting existing connections. Unchanged file contents
leave the certificate untouched. If either file cannot be read or the pair is
invalid, the proxy keeps its previous certificate and reports the error in its
status and logs; reload still succeeds. A successful retry clears the error.
Generated self-signed certificates are unchanged, and Store API updates do not
refresh certificates on unchanged proxies.
**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).
+11 -10
View File
@@ -14,16 +14,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<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">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
@@ -41,6 +31,17 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<b>The complete IDE crafted for professional Go developers</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">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end-->
## 为什么使用 frp ?
+6 -4
View File
@@ -1,9 +1,11 @@
## Features
* UDP packet payloads for ordinary UDP proxies and SUDP now use a dedicated binary codec when frpc and frps successfully negotiate the capability under wire protocol v2, using a more compact wire representation. Wire protocol v1 remains JSON; wire protocol v2 falls back to JSON `UDPPacket` when the peer does not support or did not negotiate the capability.
* frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`.
* `frpc reload` now refreshes certificate files for unchanged `https2http` and `https2https` proxies without interrupting existing connections. Failed refreshes keep the previous certificate and report the error in proxy status and logs while reload still succeeds.
* Added `metricsServer` to both frps and frpc, a separate listener for health checks and optional profiling that does not expose the dashboard or the admin API. It is disabled unless `metricsServer.port` is set, defaults to `127.0.0.1`, and has its own credentials and TLS. In frps, setting the port also serves `/metrics` with both runtime and business metrics, so `enablePrometheus` is not needed there.
The `/healthz`, `/metrics` and pprof endpoints on `webServer`, and `enablePrometheus`, still work but are deprecated: configure `metricsServer` instead. They will be removed in a later release.
## Fixes
* Fixed a server panic and remote denial of service caused by a client sending a negative `pool_count`. Negative values are now rejected before work-connection pool resources are allocated.
* Fixed `frpc verify` ignoring configured `featureGates`, which caused VirtualNet configurations to be rejected even when the feature was enabled.
* Fixed a case-insensitive validation bypass that allowed `customDomains` under the configured `subDomainHost` to be registered using mixed-case domain names.
* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection.
+20 -1
View File
@@ -45,10 +45,29 @@ func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
var reloadPlugins func()
err = func() error {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
// Snapshot plugins and apply config in the same transaction as control handoff.
m.svr.ctlMu.RLock()
ctl := m.svr.ctl
m.svr.ctlMu.RUnlock()
if ctl != nil {
reloadPlugins = ctl.pm.PreparePluginReload()
}
return m.svr.updateConfigSourceLocked(result.Common, result.Proxies, result.Visitors)
}()
if err != nil {
return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
}
// Certificate file I/O must not hold the reload transaction lock.
if reloadPlugins != nil {
reloadPlugins()
}
log.Infof("success reload conf")
return nil
}
+316
View File
@@ -0,0 +1,316 @@
// 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.
//go:build !frps
package client
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/client/visitor"
"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"
"github.com/fatedier/frp/pkg/transport"
)
type certificateReloadSender struct{ started chan string }
func (s *certificateReloadSender) Send(m msg.Message) error {
if start, ok := m.(*msg.NewProxy); ok {
s.started <- start.ProxyName
}
return nil
}
func writeReloadCertificate(t *testing.T, crtPath, keyPath string) []byte {
t.Helper()
cfg, err := transport.NewServerTLSConfig("", "", "")
require.NoError(t, err)
cert := cfg.Certificates[0]
key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
require.NoError(t, err)
require.NoError(t, os.WriteFile(crtPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0o600))
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
return cert.Certificate[0]
}
func TestFileReloadCertificatesAndStoreMutations(t *testing.T) {
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
t.Run(pluginType, func(t *testing.T) {
dir := t.TempDir()
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
original := writeReloadCertificate(t, crtPath, keyPath)
configPath := filepath.Join(dir, "frpc.toml")
content := fmt.Sprintf(`[[proxies]]
name = "https"
type = "tcp"
remotePort = 8443
[proxies.plugin]
type = %q
localAddr = "127.0.0.1:80"
crtPath = %q
keyPath = %q
`, pluginType, crtPath, keyPath)
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600))
result, err := config.LoadClientConfigResult(configPath, true)
require.NoError(t, err)
configSource := source.NewConfigSource()
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{Path: filepath.Join(dir, "store.json")})
require.NoError(t, err)
aggregator := source.NewAggregator(configSource)
aggregator.SetStoreSource(storeSource)
sender := &certificateReloadSender{started: make(chan string, 4)}
transporter := transport.NewMessageTransporter(sender)
ctx := context.Background()
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
t.Cleanup(pm.Close)
t.Cleanup(vm.Close)
svr := &Service{
common: result.Common,
configFilePath: configPath,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
storeSource: storeSource,
ctl: &Control{pm: pm, vm: vm},
}
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
select {
case name := <-sender.started:
require.Equal(t, "https", name)
case <-time.After(5 * time.Second):
t.Fatal("proxy did not request startup")
}
require.NoError(t, pm.StartProxy("https", ":8443", ""))
manager := &serviceConfigManager{svr: svr}
connect := func(wantCert []byte) *tls.Conn {
t.Helper()
clientRaw, serverRaw := net.Pipe()
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
require.NoError(t, conn.Handshake())
require.Equal(t, wantCert, conn.ConnectionState().PeerCertificates[0].Raw)
return conn
}
oldConn := connect(original)
reader := bufio.NewReader(oldConn)
checkExistingConnection := func() {
t.Helper()
require.NoError(t, oldConn.SetDeadline(time.Now().Add(5*time.Second)))
// A mismatched Host is handled locally, so no upstream network is needed.
_, err := io.WriteString(oldConn, "GET / HTTP/1.1\r\nHost: other.example\r\n\r\n")
require.NoError(t, err)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode)
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}
checkExistingConnection()
require.NoError(t, manager.ReloadFromFile(true))
require.NoError(t, connect(original).NetConn().Close())
next := writeReloadCertificate(t, crtPath, keyPath)
// Both store mutation helpers must leave unchanged proxies' certificates alone.
_, err = manager.CreateStoreProxy(result.Proxies[0].Clone())
require.NoError(t, err)
require.NoError(t, connect(original).NetConn().Close())
_, err = manager.UpdateStoreProxy("https", result.Proxies[0].Clone())
require.NoError(t, err)
require.NoError(t, connect(original).NetConn().Close())
require.NoError(t, manager.DeleteStoreProxy("https"))
require.NoError(t, connect(original).NetConn().Close())
require.NoError(t, manager.ReloadFromFile(true))
require.NoError(t, connect(next).NetConn().Close())
checkExistingConnection()
for _, badFile := range []string{"missing", "invalid"} {
saved, err := os.ReadFile(crtPath)
require.NoError(t, err)
if badFile == "missing" {
require.NoError(t, os.Remove(crtPath))
} else {
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
}
require.NoError(t, manager.ReloadFromFile(true), "certificate errors must not fail reload")
status, ok := pm.GetProxyStatus("https")
require.True(t, ok)
require.Equal(t, proxy.ProxyPhaseRunning, status.Phase)
require.Contains(t, status.Err, "reload plugin ")
require.NoError(t, connect(next).NetConn().Close())
checkExistingConnection()
require.NoError(t, os.WriteFile(crtPath, saved, 0o600))
require.NoError(t, manager.ReloadFromFile(true))
status, _ = pm.GetProxyStatus("https")
require.Empty(t, status.Err, "restoring the active bytes must clear the reload error")
}
// A config change still replaces the proxy and uses normal startup errors.
require.NoError(t, oldConn.NetConn().Close())
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
changedConfig := strings.Replace(content, "remotePort = 8443", "remotePort = 8444", 1)
require.NoError(t, os.WriteFile(configPath, []byte(changedConfig), 0o600))
require.NoError(t, manager.ReloadFromFile(true))
select {
case name := <-sender.started:
require.Equal(t, "https", name)
case <-time.After(5 * time.Second):
t.Fatal("replacement proxy did not request startup")
}
require.Error(t, pm.StartProxy("https", ":8444", ""))
status, ok := pm.GetProxyStatus("https")
require.True(t, ok)
require.Equal(t, proxy.ProxyPhaseStartErr, status.Phase)
require.NotEmpty(t, status.Err)
})
}
}
func TestFileReloadCertificatesDuringControlHandoff(t *testing.T) {
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
t.Run(pluginType, func(t *testing.T) {
dir := t.TempDir()
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
original := writeReloadCertificate(t, crtPath, keyPath)
configPath := filepath.Join(dir, "frpc.toml")
require.NoError(t, os.WriteFile(configPath, fmt.Appendf(nil, `[[proxies]]
name = "https"
type = "tcp"
remotePort = 8443
[proxies.plugin]
type = %q
localAddr = "127.0.0.1:80"
crtPath = %q
keyPath = %q
`, pluginType, crtPath, keyPath), 0o600))
result, err := config.LoadClientConfigResult(configPath, true)
require.NoError(t, err)
configSource := source.NewConfigSource()
svr := &Service{
common: result.Common,
configFilePath: configPath,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: source.NewAggregator(configSource),
configSource: configSource,
}
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
sender := &certificateReloadSender{started: make(chan string, 1)}
transporter := transport.NewMessageTransporter(sender)
newControl := func() *Control {
ctx := context.Background()
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
t.Cleanup(pm.Close)
t.Cleanup(vm.Close)
return &Control{pm: pm, vm: vm}
}
previous := newControl()
previous.pm.Close()
svr.ctl = previous
next := newControl()
// Model the handoff transaction: start the new control's proxies while
// the published control is still the closed, empty previous manager.
svr.reloadMu.Lock()
releaseTransaction := sync.OnceFunc(svr.reloadMu.Unlock)
defer releaseTransaction()
next.pm.UpdateAll(svr.proxyCfgs)
select {
case name := <-sender.started:
require.Equal(t, "https", name)
case <-time.After(5 * time.Second):
t.Fatal("replacement proxy did not request startup")
}
require.NoError(t, next.pm.StartProxy("https", ":8443", ""))
checkCertificate := func(want []byte) {
t.Helper()
clientRaw, serverRaw := net.Pipe()
defer clientRaw.Close()
defer serverRaw.Close()
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
next.pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
require.NoError(t, conn.Handshake())
require.Equal(t, want, conn.ConnectionState().PeerCertificates[0].Raw)
}
checkCertificate(original)
renewed := writeReloadCertificate(t, crtPath, keyPath)
svr.ctlMu.Lock()
releaseControl := sync.OnceFunc(svr.ctlMu.Unlock)
defer releaseControl()
reloadStarted, reloadDone := make(chan struct{}), make(chan struct{})
var reloadErr error
go func() {
close(reloadStarted)
reloadErr = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
close(reloadDone)
}()
t.Cleanup(func() {
releaseTransaction()
releaseControl()
waitLifecycleSignal(t, reloadDone)
})
waitLifecycleSignal(t, reloadStarted)
svr.ctl = next
releaseTransaction()
// Keep ctlMu as a read barrier after publication. Reload must acquire
// reloadMu before selecting ctl, independently of goroutine scheduling.
// The deadline only detects a broken lock order; it does not stage the race.
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for svr.reloadMu.TryLock() {
svr.reloadMu.Unlock()
select {
case <-deadline.C:
t.Fatal("reload selected ctl without first acquiring reloadMu")
default:
runtime.Gosched()
}
}
releaseControl()
waitLifecycleSignal(t, reloadDone)
require.NoError(t, reloadErr)
checkCertificate(renewed)
})
}
}
+7 -5
View File
@@ -24,7 +24,7 @@ import (
"time"
libnet "github.com/fatedier/golib/net"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -109,11 +109,12 @@ func (c *defaultConnectorImpl) Open() error {
sn = c.cfg.ServerAddr
}
if lo.FromPtr(c.cfg.Transport.TLS.Enable) {
tlsConfig, err = transport.NewClientTLSConfig(
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
c.cfg.Transport.TLS.CertFile,
c.cfg.Transport.TLS.KeyFile,
c.cfg.Transport.TLS.TrustedCaFile,
sn)
sn,
c.cfg.Transport.TLS.UseSystemCa)
} else {
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
}
@@ -194,11 +195,12 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
sn = c.cfg.ServerAddr
}
tlsConfig, err = transport.NewClientTLSConfig(
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
c.cfg.Transport.TLS.CertFile,
c.cfg.Transport.TLS.KeyFile,
c.cfg.Transport.TLS.TrustedCaFile,
sn)
sn,
c.cfg.Transport.TLS.UseSystemCa)
if err != nil {
xl.Warnf("fail to build tls configuration, err: %v", err)
return nil, err
+2 -39
View File
@@ -16,9 +16,7 @@ package client
import (
"context"
"fmt"
"net"
"strings"
"sync/atomic"
"time"
@@ -171,44 +169,9 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
if err != nil {
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
xl.Warnf("[%s] start error: %v", proxyName, err)
} else {
xl.Infof("[%s] 成功启动隧道", proxyName)
if inMsg.RemoteAddr != "" {
// Get proxy type to format access message
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
proxyType := status.Type
remoteAddr := inMsg.RemoteAddr
var accessMsg string
switch proxyType {
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
if strings.HasPrefix(remoteAddr, ":") {
serverAddr := ctl.sessionCtx.Common.ServerAddr
remoteAddr = serverAddr + remoteAddr
}
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
case "http", "https":
// Format as URL with protocol
protocol := proxyType
addr := remoteAddr
// Remove standard ports for cleaner URL
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
addr = strings.TrimSuffix(addr, ":80")
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
addr = strings.TrimSuffix(addr, ":443")
}
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
default:
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
}
xl.Infof("[%s] %s", proxyName, accessMsg)
} else {
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
}
}
xl.Infof("[%s] start proxy success", proxyName)
}
}
+51 -14
View File
@@ -30,6 +30,11 @@ import (
var ErrHealthCheckType = errors.New("error health check type")
func newHealthTimer(interval time.Duration) (<-chan time.Time, func()) {
timer := time.NewTimer(interval)
return timer.C, func() { timer.Stop() }
}
type Monitor struct {
checkType string
interval time.Duration
@@ -49,6 +54,9 @@ type Monitor struct {
ctx context.Context
cancel context.CancelFunc
doneCh chan struct{}
timerFactory func(time.Duration) (<-chan time.Time, func())
}
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
@@ -91,6 +99,8 @@ func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
statusFailedFn: statusFailedFn,
ctx: newctx,
cancel: cancel,
doneCh: make(chan struct{}),
timerFactory: newHealthTimer,
}
}
@@ -102,29 +112,48 @@ func (monitor *Monitor) Stop() {
monitor.cancel()
}
func (monitor *Monitor) checkWorker() {
xl := xlog.FromContextSafe(monitor.ctx)
for {
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
err := monitor.doCheck(doCtx)
// Done is closed when the worker launched by Start has exited.
func (monitor *Monitor) Done() <-chan struct{} {
return monitor.doneCh
}
// check if this monitor has been closed
select {
case <-monitor.ctx.Done():
cancel()
func (monitor *Monitor) checkWorker() {
defer close(monitor.doneCh)
for {
if monitor.ctx.Err() != nil {
return
default:
cancel()
}
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
err := monitor.doCheck(doCtx)
cancel()
// check if this monitor has been closed
if monitor.ctx.Err() != nil {
return
}
monitor.handleCheckResult(err)
if !monitor.waitForNextCheck() {
return
}
}
}
func (monitor *Monitor) handleCheckResult(err error) {
xl := xlog.FromContextSafe(monitor.ctx)
if err == nil {
xl.Tracef("do one health check success")
monitor.failedTimes = 0
if !monitor.statusOK && monitor.statusNormalFn != nil {
xl.Infof("health check status change to success")
monitor.statusOK = true
monitor.statusNormalFn()
}
} else {
return
}
xl.Warnf("do one health check failed: %v", err)
monitor.failedTimes++
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
@@ -132,9 +161,17 @@ func (monitor *Monitor) checkWorker() {
monitor.statusOK = false
monitor.statusFailedFn()
}
}
}
time.Sleep(monitor.interval)
func (monitor *Monitor) waitForNextCheck() bool {
timerC, stopTimer := monitor.timerFactory(monitor.interval)
defer stopTimer()
select {
case <-monitor.ctx.Done():
return false
case <-timerC:
return monitor.ctx.Err() == nil
}
}
+575
View File
@@ -0,0 +1,575 @@
// 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 health
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type tcpHealthBackend struct {
listener net.Listener
accepted chan struct{}
done chan struct{}
}
func newTCPHealthBackend(t *testing.T, addr string, accepted chan struct{}) *tcpHealthBackend {
listener, err := net.Listen("tcp", addr)
require.NoError(t, err)
backend := &tcpHealthBackend{
listener: listener,
accepted: accepted,
done: make(chan struct{}),
}
go func() {
defer close(backend.done)
for {
conn, err := backend.listener.Accept()
if err != nil {
return
}
_ = conn.Close()
select {
case backend.accepted <- struct{}{}:
default:
}
}
}()
return backend
}
func (backend *tcpHealthBackend) Close() {
_ = backend.listener.Close()
<-backend.done
}
func TestMonitorConsecutiveFailureWindows(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, func(), func()) (*Monitor, func(bool))
}{
{
name: "HTTP",
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
var healthy atomic.Bool
healthy.Store(true)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if !healthy.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
t.Cleanup(server.Close)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "http",
Path: "/health",
TimeoutSeconds: 1,
IntervalSeconds: 1,
MaxFailed: 3,
},
strings.TrimPrefix(server.URL, "http://"),
normalFn,
failedFn,
)
return monitor, healthy.Store
},
},
{
name: "TCP",
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "tcp",
TimeoutSeconds: 1,
IntervalSeconds: 1,
MaxFailed: 3,
},
listener.Addr().String(),
normalFn,
failedFn,
)
healthyAddr := monitor.addr
return monitor, func(healthy bool) {
if healthy {
monitor.addr = healthyAddr
} else {
monitor.addr = "127.0.0.1:0"
}
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var events []string
monitor, setHealthy := test.setup(
t,
func() { events = append(events, "normal") },
func() { events = append(events, "failed") },
)
t.Cleanup(monitor.Stop)
runCheck := func(healthy bool) {
t.Helper()
setHealthy(healthy)
ctx, cancel := context.WithTimeout(monitor.ctx, time.Second)
err := monitor.doCheck(ctx)
cancel()
if healthy {
require.NoError(t, err)
} else {
require.Error(t, err)
}
monitor.handleCheckResult(err)
}
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(2), monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(1), monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(2), monitor.failedTimes)
runCheck(false)
require.False(t, monitor.statusOK)
require.Equal(t, uint64(3), monitor.failedTimes)
require.Equal(t, []string{"normal", "failed"}, events)
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal", "failed", "normal"}, events)
})
}
}
type workerStatus struct {
failedTimes uint64
statusOK bool
}
type workerInterval struct {
status workerStatus
timer chan time.Time
}
func observeWorkerIntervals(monitor *Monitor) <-chan workerInterval {
intervals := make(chan workerInterval, 1)
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
timer := make(chan time.Time, 1)
// The worker takes this snapshot after handleCheckResult. Sending it
// publishes the state to the test; no test goroutine reads live fields.
interval := workerInterval{
status: workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK},
timer: timer,
}
select {
case intervals <- interval:
case <-monitor.ctx.Done():
}
return timer, func() {}
}
return intervals
}
func awaitWorkerInterval(t *testing.T, intervals <-chan workerInterval, want workerStatus) chan time.Time {
t.Helper()
select {
case interval := <-intervals:
require.Equal(t, want, interval.status)
return interval.timer
case <-time.After(time.Second):
t.Fatal("health worker did not reach the interval barrier")
return nil
}
}
func stopMonitorWorker(t *testing.T, monitor *Monitor) {
t.Helper()
monitor.Stop()
select {
case <-monitor.Done():
case <-time.After(time.Second):
t.Error("health worker did not exit after Stop")
}
}
func TestMonitorWorkerProcessesResults(t *testing.T) {
requestReady := make(chan struct{}, 1)
responses := make(chan int)
requestCanceled := make(chan struct{})
var cancelOnce sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case requestReady <- struct{}{}:
case <-r.Context().Done():
return
}
select {
case code := <-responses:
w.WriteHeader(code)
case <-r.Context().Done():
cancelOnce.Do(func() { close(requestCanceled) })
}
}))
t.Cleanup(server.Close)
events := make(chan string, 3)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "http",
Path: "/health",
TimeoutSeconds: 5,
MaxFailed: 3,
},
strings.TrimPrefix(server.URL, "http://"),
func() { events <- "normal" },
func() { events <- "failed" },
)
intervals := observeWorkerIntervals(monitor)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
awaitRequest := func() {
t.Helper()
select {
case <-requestReady:
case <-time.After(time.Second):
t.Fatal("health check request did not start")
}
}
awaitEvent := func(want string) {
t.Helper()
select {
case got := <-events:
require.Equal(t, want, got)
case <-time.After(time.Second):
t.Fatalf("health check callback %q was not called", want)
}
}
checkResult := func(code int, want workerStatus, event string) {
t.Helper()
awaitRequest()
select {
case responses <- code:
case <-time.After(time.Second):
t.Fatal("health check handler did not accept the response")
}
timer := awaitWorkerInterval(t, intervals, want)
if event != "" {
awaitEvent(event)
}
// The worker has finished this result and cannot emit another
// callback until the test releases the next check.
select {
case got := <-events:
t.Fatalf("unexpected health check callback %q", got)
default:
}
timer <- time.Now()
}
monitor.Start()
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
checkResult(http.StatusOK, workerStatus{statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 3}, "failed")
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
awaitRequest()
stopMonitorWorker(t, monitor)
select {
case <-requestCanceled:
case <-time.After(time.Second):
t.Fatal("health check request context was not canceled")
}
}
func TestMonitorTCPWorkerProcessesResults(t *testing.T) {
initialAccepted := make(chan struct{}, 1)
initialBackend := newTCPHealthBackend(t, "127.0.0.1:0", initialAccepted)
addr := initialBackend.listener.Addr().String()
recoveryAccepted := make(chan struct{}, 1)
normalCallbacks := make(chan workerStatus, 2)
failedCallbacks := make(chan workerStatus, 2)
var monitor *Monitor
monitor = NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "tcp",
TimeoutSeconds: 1,
MaxFailed: 3,
},
addr,
func() {
normalCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
},
func() {
failedCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
},
)
intervals := observeWorkerIntervals(monitor)
recoveryBackend := (*tcpHealthBackend)(nil)
t.Cleanup(func() {
stopMonitorWorker(t, monitor)
initialBackend.Close()
if recoveryBackend != nil {
recoveryBackend.Close()
}
})
awaitStatus := func(ch <-chan workerStatus, want workerStatus, message string) {
t.Helper()
select {
case got := <-ch:
require.Equal(t, want, got)
case <-time.After(time.Second):
t.Fatal(message)
}
}
monitor.Start()
select {
case <-initialAccepted:
case <-time.After(time.Second):
t.Fatal("TCP health check did not reach the initial backend")
}
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report the initial success")
initialTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
initialBackend.Close()
initialTimer <- time.Now()
firstFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
firstFailureTimer <- time.Now()
secondFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
secondFailureTimer <- time.Now()
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check")
thirdFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 3})
recoveryBackend = newTCPHealthBackend(t, addr, recoveryAccepted)
thirdFailureTimer <- time.Now()
select {
case <-recoveryAccepted:
case <-time.After(time.Second):
t.Fatal("TCP health check did not reach the recovery backend")
}
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report recovery")
recoveryTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
recoveryBackend.Close()
recoveryTimer <- time.Now()
firstRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
firstRecoveryFailureTimer <- time.Now()
secondRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
secondRecoveryFailureTimer <- time.Now()
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check after recovery")
stopMonitorWorker(t, monitor)
}
func TestMonitorMaxFailedOne(t *testing.T) {
var events []string
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "tcp", MaxFailed: 1},
"",
func() { events = append(events, "normal") },
func() { events = append(events, "failed") },
)
t.Cleanup(monitor.Stop)
checkErr := errors.New("health check failed")
monitor.handleCheckResult(nil)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed"}, events)
require.False(t, monitor.statusOK)
require.Equal(t, uint64(1), monitor.failedTimes)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed"}, events)
monitor.handleCheckResult(nil)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed", "normal", "failed"}, events)
}
func TestMonitorStopCancelsWork(t *testing.T) {
t.Run("in-flight check", func(t *testing.T) {
requestStarted := make(chan struct{})
requestCanceled := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
close(requestStarted)
<-r.Context().Done()
close(requestCanceled)
}))
t.Cleanup(server.Close)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "http", Path: "/health"},
strings.TrimPrefix(server.URL, "http://"),
func() {},
func() {},
)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
monitor.Start()
select {
case <-requestStarted:
case <-time.After(time.Second):
t.Fatal("health check request did not start")
}
stopMonitorWorker(t, monitor)
select {
case <-requestCanceled:
case <-time.After(time.Second):
t.Fatal("health check request context was not canceled")
}
})
t.Run("interval wait", func(t *testing.T) {
monitor := NewMonitor(context.Background(), v1.HealthCheckConfig{Type: "tcp"}, "", nil, nil)
intervals := observeWorkerIntervals(monitor)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
monitor.Start()
// A real worker has completed its first check and installed a timer
// that will never fire. Stop must release the wait and exit the loop.
awaitWorkerInterval(t, intervals, workerStatus{})
stopMonitorWorker(t, monitor)
})
}
type gatedErrContext struct {
context.Context
errCalled chan struct{}
releaseErr chan struct{}
}
func (ctx *gatedErrContext) Err() error {
select {
case ctx.errCalled <- struct{}{}:
default:
}
<-ctx.releaseErr
return ctx.Context.Err()
}
func TestMonitorTimerCancellationAfterTimerFires(t *testing.T) {
baseCtx, cancel := context.WithCancel(context.Background())
ctx := &gatedErrContext{
Context: baseCtx,
errCalled: make(chan struct{}, 1),
releaseErr: make(chan struct{}),
}
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "tcp"},
"",
nil,
nil,
)
monitor.ctx = ctx
monitor.cancel = cancel
timerReady := make(chan time.Time, 1)
timerReady <- time.Now()
var timerStopped atomic.Bool
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
return timerReady, func() { timerStopped.Store(true) }
}
waitResult := make(chan bool, 1)
go func() {
waitResult <- monitor.waitForNextCheck()
}()
// ctx.Done is not ready when select runs, so receiving timerReady is the
// only possible branch. Err signals after that receive and blocks until the
// test cancels the context, deterministically exercising the cancellation
// re-check in the timer branch.
select {
case <-ctx.errCalled:
case <-time.After(time.Second):
t.Fatal("timer branch did not re-check the monitor context")
}
cancel()
close(ctx.releaseErr)
select {
case shouldContinue := <-waitResult:
require.False(t, shouldContinue)
case <-time.After(time.Second):
t.Fatal("timer wait did not observe context cancellation")
}
require.True(t, timerStopped.Load())
}
+1 -1
View File
@@ -149,7 +149,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
}
psr.Plugin = baseCfg.Plugin.Type
if status.Err == "" {
if status.Err == "" || status.Phase == proxy.ProxyPhaseRunning {
psr.RemoteAddr = status.RemoteAddr
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
+29
View File
@@ -213,6 +213,35 @@ func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
}
}
func TestBuildProxyStatusRespRunningWithErrorKeepsRemoteAddr(t *testing.T) {
const certificateErr = "reload plugin https2http: invalid certificate"
controller := &Controller{serverAddr: "127.0.0.1", manager: &fakeConfigManager{}}
for _, tc := range []struct {
name, phase, err, remoteAddr string
}{
{"running", proxy.ProxyPhaseRunning, "", "127.0.0.1:43210"},
{"running with error", proxy.ProxyPhaseRunning, certificateErr, "127.0.0.1:43210"},
{"start error", proxy.ProxyPhaseStartErr, "start failed", ""},
{"closed with error", proxy.ProxyPhaseClosed, certificateErr, ""},
{"closed without error", proxy.ProxyPhaseClosed, "", "127.0.0.1:43210"},
} {
t.Run(tc.name, func(t *testing.T) {
status := &proxy.WorkingStatus{
Name: "https", Type: "tcp", Phase: tc.phase, Err: tc.err,
RemoteAddr: ":43210",
Cfg: newRawTCPProxyConfig("https"), // RemotePort remains zero (allocated by frps).
}
resp := controller.buildProxyStatusResp(status)
if resp.RemoteAddr != tc.remoteAddr {
t.Fatalf("unexpected remote addr: got %q, want %q", resp.RemoteAddr, tc.remoteAddr)
}
if resp.Err != tc.err || resp.Status != tc.phase {
t.Fatalf("status or error changed: %+v", resp)
}
})
}
}
func TestReloadErrorMapping(t *testing.T) {
tests := []struct {
name string
+91
View File
@@ -0,0 +1,91 @@
// 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 client
import (
"context"
"net"
"net/http"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewServiceReleasesWebServerOnMetricsServerFailure(t *testing.T) {
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
t.Run(mode, func(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
cfg := &v1.ClientCommonConfig{
WebServer: v1.WebServerConfig{Port: getFreeTCPPort(t)},
MetricsServer: v1.MetricsServerConfig{Port: ln.Addr().(*net.TCPAddr).Port},
}
if mode == "invalid TLS certificate" {
cfg.MetricsServer.TLS = &v1.TLSConfig{
CertFile: filepath.Join(t.TempDir(), "missing.crt"), KeyFile: "missing.key",
}
require.NoError(t, ln.Close())
}
svr, err := NewService(ServiceOptions{
Common: cfg, ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
})
if svr != nil {
svr.Close()
}
require.Error(t, err)
require.Nil(t, svr)
assertLifecycleAdminPort(t, net.JoinHostPort(cfg.WebServer.Addr, strconv.Itoa(cfg.WebServer.Port)), true)
if mode == "invalid TLS certificate" {
assertLifecycleAdminPort(t, ln.Addr().String(), true)
}
})
}
}
func TestServiceMetricsServerLifecycle(t *testing.T) {
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: new(false), MetricsServer: v1.MetricsServerConfig{Port: getFreeTCPPort(t)},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
return &failingConnector{err: net.ErrClosed}
},
})
require.NoError(t, err)
require.Nil(t, svr.webServer)
address := svr.metricsServer.Address()
done := make(chan struct{})
go func() { _ = svr.Run(context.Background()); close(done) }()
defer func() {
svr.Close()
waitLifecycleSignal(t, done)
assertLifecycleAdminPort(t, address, true)
}()
client := &http.Client{Timeout: 3 * time.Second}
for path, status := range map[string]int{"/healthz": http.StatusOK, "/metrics": http.StatusNotFound} {
resp, err := client.Get("http://" + address + path)
require.NoError(t, err)
require.Equal(t, status, resp.StatusCode)
require.NoError(t, resp.Body.Close())
}
}
+148
View File
@@ -0,0 +1,148 @@
// 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.
//go:build !frps
package proxy
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
plugin "github.com/fatedier/frp/pkg/plugin/client"
"github.com/fatedier/frp/pkg/util/xlog"
)
type reloadError struct {
err error
}
type testReloadPlugin struct {
calls atomic.Int32
state atomic.Pointer[reloadError]
reload func() error
}
func (p *testReloadPlugin) Name() string { return "test" }
func (p *testReloadPlugin) Handle(context.Context, *plugin.ConnectionInfo) {}
func (p *testReloadPlugin) Close() error { return nil }
func (p *testReloadPlugin) Reload() error {
p.calls.Add(1)
err := p.reload()
p.state.Store(&reloadError{err: err})
return err
}
func (p *testReloadPlugin) ReloadError() error {
if s := p.state.Load(); s != nil {
return s.err
}
return nil
}
func reloadFixture(p *testReloadPlugin) (*Manager, *Wrapper, *BaseProxy) {
base := &BaseProxy{proxyPlugin: p}
pw := &Wrapper{
WorkingStatus: WorkingStatus{Name: "same", Phase: ProxyPhaseRunning},
pxy: &GeneralTCPProxy{BaseProxy: base},
xl: xlog.New(),
}
return &Manager{proxies: map[string]*Wrapper{"same": pw}}, pw, base
}
func TestPluginSnapshotSkipsReplacedAndStopped(t *testing.T) {
p := &testReloadPlugin{reload: func() error { return nil }}
pm, pw, base := reloadFixture(p)
run := pm.PreparePluginReload()
q := &testReloadPlugin{reload: func() error { return nil }}
pw.mu.Lock()
base.proxyPlugin = q
pw.mu.Unlock()
run()
require.Zero(t, p.calls.Load())
require.Zero(t, q.calls.Load())
run = pm.PreparePluginReload()
pw.mu.Lock()
pw.Phase = ProxyPhaseClosed
pw.mu.Unlock()
run()
require.Zero(t, q.calls.Load())
}
func TestPluginErrorOwnershipAndLockIsolation(t *testing.T) {
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
p := &testReloadPlugin{reload: func() error {
close(started)
<-release
return errors.New("bad certificate")
}}
pm, pw, base := reloadFixture(p)
other := &testReloadPlugin{reload: func() error { return nil }}
pm2, _, _ := reloadFixture(other)
run := pm.PreparePluginReload()
releaseReload := sync.OnceFunc(func() { close(release) })
t.Cleanup(func() {
releaseReload()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("reload did not finish")
}
})
go func() {
run()
close(done)
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("not started")
}
require.True(t, pm.mu.TryLock())
pm.mu.Unlock()
require.True(t, pw.mu.TryLock())
pw.mu.Unlock()
require.Empty(t, pw.GetStatus().Err)
require.Zero(t, other.calls.Load(), "same name in another manager must be isolated")
pm2.PreparePluginReload()()
require.EqualValues(t, 1, other.calls.Load())
q := &testReloadPlugin{reload: func() error { return nil }}
pw.mu.Lock()
base.proxyPlugin = q
pw.mu.Unlock()
releaseReload()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("not done")
}
require.Error(t, p.ReloadError())
require.Empty(t, pw.GetStatus().Err, "retired plugin errors must not attach to its replacement")
q.state.Store(&reloadError{err: errors.New("new plugin error")})
require.Contains(t, pw.GetStatus().Err, "new plugin error")
require.Empty(t, pw.Err, "plugin state must not overwrite lifecycle errors")
pw.mu.Lock()
pw.Err = "lifecycle"
pw.mu.Unlock()
require.Contains(t, pw.GetStatus().Err, "lifecycle; reload plugin")
require.NoError(t, q.Reload())
require.Equal(t, "lifecycle", pw.GetStatus().Err)
}
+2 -37
View File
@@ -20,9 +20,7 @@ import (
"io"
"net"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -73,7 +71,6 @@ func NewProxy(
baseProxy := BaseProxy{
baseCfg: pxyConf.GetBaseConfig(),
configurer: pxyConf,
clientCfg: clientCfg,
encryptionKey: encryptionKey,
limiter: limiter,
@@ -93,7 +90,6 @@ func NewProxy(
type BaseProxy struct {
baseCfg *v1.ProxyBaseConfig
configurer v1.ProxyConfigurer
clientCfg *v1.ClientCommonConfig
encryptionKey []byte
msgTransporter transport.MessageTransporter
@@ -114,7 +110,6 @@ func (pxy *BaseProxy) Run() error {
if pxy.baseCfg.Plugin.Type != "" {
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
Name: pxy.baseCfg.Name,
HostAllowList: pxy.getPluginHostAllowList(),
VnetController: pxy.vnetController,
}, pxy.baseCfg.Plugin.ClientPluginOptions)
if err != nil {
@@ -125,38 +120,8 @@ func (pxy *BaseProxy) Run() error {
return nil
}
func (pxy *BaseProxy) getPluginHostAllowList() []string {
dedupHosts := make([]string, 0)
addHost := func(host string) {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return
}
// autocert.HostWhitelist only supports exact host names.
if strings.Contains(host, "*") {
return
}
if !slices.Contains(dedupHosts, host) {
dedupHosts = append(dedupHosts, host)
}
}
switch cfg := pxy.configurer.(type) {
case *v1.HTTPProxyConfig:
for _, host := range cfg.CustomDomains {
addHost(host)
}
case *v1.HTTPSProxyConfig:
for _, host := range cfg.CustomDomains {
addHost(host)
}
case *v1.TCPMuxProxyConfig:
for _, host := range cfg.CustomDomains {
addHost(host)
}
}
return dedupHosts
}
// getPlugin is accessed while the owning Wrapper holds its lifecycle lock.
func (pxy *BaseProxy) getPlugin() plugin.Plugin { return pxy.proxyPlugin }
func (pxy *BaseProxy) Close() {
if pxy.proxyPlugin != nil {
+37 -2
View File
@@ -139,6 +139,41 @@ func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) {
return nil, false
}
// PreparePluginReload snapshots live plugin instances before applying config.
// Invoke the returned function only after config application succeeds and all
// configuration locks have been released. Replaced instances are skipped.
func (pm *Manager) PreparePluginReload() func() {
type target struct {
pw *Wrapper
p reloadablePlugin
}
pm.mu.RLock()
targets := make([]target, 0, len(pm.proxies))
for _, pw := range pm.proxies {
pw.mu.RLock()
if pw.Phase == ProxyPhaseRunning {
if p := pw.reloadablePluginLocked(); p != nil {
targets = append(targets, target{pw: pw, p: p})
}
}
pw.mu.RUnlock()
}
pm.mu.RUnlock()
return func() {
for _, t := range targets {
t.pw.mu.RLock()
current := t.pw.Phase == ProxyPhaseRunning && t.pw.reloadablePluginLocked() == t.p
t.pw.mu.RUnlock()
if !current {
continue
}
if err := t.p.Reload(); err != nil {
t.pw.xl.Warnf("reload plugin %s: %v", t.p.Name(), err)
}
}
}
}
func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
xl := xlog.FromContextSafe(pm.ctx)
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
@@ -162,7 +197,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
}
}
if len(delPxyNames) > 0 {
xl.Infof("隧道移除: %s", delPxyNames)
xl.Infof("proxy removed: %s", delPxyNames)
}
addPxyNames := make([]string, 0)
@@ -180,6 +215,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
}
}
if len(addPxyNames) > 0 {
xl.Infof("添加隧道: %s", addPxyNames)
xl.Infof("proxy added: %s", addPxyNames)
}
}
+32 -1
View File
@@ -30,6 +30,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/naming"
plugin "github.com/fatedier/frp/pkg/plugin/client"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -267,8 +268,9 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
xl := pw.xl
pw.mu.RLock()
pxy := pw.pxy
running := pw.Phase == ProxyPhaseRunning
pw.mu.RUnlock()
if pxy != nil && pw.Phase == ProxyPhaseRunning {
if pxy != nil && running {
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
go pxy.InWorkConn(workConn, m)
} else {
@@ -276,6 +278,25 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
}
}
// reloadablePlugin owns its reload serialization and error snapshot. ReloadError
// must not block on I/O. Reload must be safe during Close, and retain usable
// resources on failure. Existing plugins need not implement this capability.
type reloadablePlugin interface {
plugin.Plugin
Reload() error
ReloadError() error
}
// Caller holds pw.mu. Concrete reloadable plugins are pointer instances.
func (pw *Wrapper) reloadablePluginLocked() reloadablePlugin {
owner, ok := pw.pxy.(interface{ getPlugin() plugin.Plugin })
if !ok {
return nil
}
p, _ := owner.getPlugin().(reloadablePlugin)
return p
}
func (pw *Wrapper) GetStatus() *WorkingStatus {
pw.mu.RLock()
defer pw.mu.RUnlock()
@@ -287,5 +308,15 @@ func (pw *Wrapper) GetStatus() *WorkingStatus {
Cfg: pw.Cfg,
RemoteAddr: pw.RemoteAddr,
}
if pw.Phase == ProxyPhaseRunning {
if p := pw.reloadablePluginLocked(); p != nil {
if err := p.ReloadError(); err != nil {
if ps.Err != "" {
ps.Err += "; "
}
ps.Err += fmt.Sprintf("reload plugin %s: %v", p.Name(), err)
}
}
}
return ps
}
+76
View File
@@ -0,0 +1,76 @@
//go:build !frps
package proxy
import (
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/xlog"
)
type closingWorkProxy struct {
Proxy
accepted atomic.Int64
}
func (p *closingWorkProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
p.accepted.Add(1)
_ = conn.Close()
}
func assertWrapperWorkConnClosed(t *testing.T, pw *Wrapper) {
t.Helper()
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
require.NoError(t, peer.SetReadDeadline(time.Now().Add(time.Second)))
pw.InWorkConn(conn, &msg.StartWorkConn{})
_, err := peer.Read(make([]byte, 1))
require.ErrorIs(t, err, io.EOF)
}
func TestWrapperInWorkConnPhaseSnapshot(t *testing.T) {
pxy := &closingWorkProxy{}
pw := &Wrapper{pxy: pxy, xl: xlog.New()}
for _, phase := range []string{ProxyPhaseNew, ProxyPhaseWaitStart, ProxyPhaseStartErr, ProxyPhaseCheckFailed, ProxyPhaseClosed} {
pw.Phase = phase
assertWrapperWorkConnClosed(t, pw)
}
require.Zero(t, pxy.accepted.Load(), "inactive proxy received a work connection")
pw.Phase = ProxyPhaseRunning
pw.pxy = nil
assertWrapperWorkConnClosed(t, pw)
pw.pxy = pxy
assertWrapperWorkConnClosed(t, pw)
require.Equal(t, int64(1), pxy.accepted.Load())
start, done := make(chan struct{}), make(chan struct{})
go func() {
<-start
for range 20000 {
pw.mu.Lock()
pw.Phase = ProxyPhaseRunning
pw.mu.Unlock()
pw.mu.Lock()
pw.Phase = ProxyPhaseClosed
pw.mu.Unlock()
}
close(done)
}()
close(start)
for range 10000 {
assertWrapperWorkConnClosed(t, pw)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("phase writer did not finish")
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
xl := pxy.xl
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
// close resources related with old workConn
pxy.Close()
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"reflect"
"time"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
"github.com/quic-go/quic-go"
v1 "github.com/fatedier/frp/pkg/config/v1"
+106 -21
View File
@@ -124,12 +124,13 @@ type Service struct {
// web server for admin UI and apis
webServer *httppkg.Server
metricsServer *httppkg.Server
vnetController *vnet.Controller
cfgMu sync.RWMutex
// reloadMu serializes reload transactions to keep reloadCommon and applied
// config in sync across concurrent API operations.
// reloadMu serializes config updates with control startup and publication,
// keeping reloadCommon and applied config in sync across reloads and reconnects.
reloadMu sync.Mutex
common *v1.ClientCommonConfig
// reloadCommon is used for filtering/defaulting during config-source reloads.
@@ -155,6 +156,9 @@ type Service struct {
ctx context.Context
// call cancel to stop service
cancel context.CancelCauseFunc
// Protect cancel publication and shutdown before the first Run.
lifecycleMu sync.Mutex
closed bool
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
@@ -185,8 +189,8 @@ func NewService(options ServiceOptions) (*Service, error) {
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
// Create the web server after all fallible steps so its listener is not
// leaked when an earlier error causes NewService to return.
// Create HTTP servers after all other fallible steps, rolling back the web
// listener if the metrics server cannot be created.
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
ws, err := httppkg.NewServer(options.Common.WebServer)
@@ -195,11 +199,19 @@ func NewService(options ServiceOptions) (*Service, error) {
}
webServer = ws
}
metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, nil)
if err != nil {
if webServer != nil {
_ = webServer.Close()
}
return nil, err
}
s := &Service{
ctx: context.Background(),
auth: authRuntime,
webServer: webServer,
metricsServer: metricsServer,
common: options.Common,
reloadCommon: options.Common,
configFilePath: options.ConfigFilePath,
@@ -225,8 +237,20 @@ func NewService(options ServiceOptions) (*Service, error) {
func (svr *Service) Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
cancel(nil)
return ctx.Err()
}
svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))
svr.cancel = cancel
svr.lifecycleMu.Unlock()
defer cancel(nil)
defer svr.stop()
if err := ctx.Err(); err != nil {
return err
}
// set custom DNSServer
if svr.common.DNSServer != "" {
@@ -237,7 +261,6 @@ func (svr *Service) Run(ctx context.Context) error {
vnetController := svr.vnetController
if err := svr.vnetController.Init(); err != nil {
log.Errorf("init virtual network controller error: %v", err)
svr.stop()
return err
}
go func() {
@@ -249,6 +272,12 @@ func (svr *Service) Run(ctx context.Context) error {
}
if svr.webServer != nil {
if svr.common.WebServer.PprofEnable {
log.Warnf("webServer pprof endpoints are deprecated; use metricsServer.pprofEnable instead")
}
if svr.common.MetricsServer.Port == 0 {
log.Infof("webServer /healthz is deprecated; health probes can migrate to metricsServer.port")
}
webServer := svr.webServer
go func() {
log.Infof("admin server listen on %s", webServer.Address())
@@ -258,24 +287,40 @@ func (svr *Service) Run(ctx context.Context) error {
}()
}
if svr.metricsServer != nil {
metricsServer := svr.metricsServer
go func() {
log.Infof("metrics server listen on %s", metricsServer.Address())
if err := metricsServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warnf("metrics server exit with error: %v", err)
}
}()
}
// first login to frps
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
if svr.ctl == nil {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
svr.stop()
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
}
go svr.keepControllerWorking()
<-svr.ctx.Done()
svr.stop()
return nil
}
func (svr *Service) keepControllerWorking() {
<-svr.ctl.Done()
// ctl is swapped under ctlMu in loopLoginUntilSuccess / stop; take a
// snapshot before waiting so stop's nil write does not race this read.
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl == nil {
return
}
<-ctl.Done()
// There is a situation where the login is successful but due to certain reasons,
// the control immediately exits. It is necessary to limit the frequency of reconnection in this case.
@@ -285,8 +330,11 @@ func (svr *Service) keepControllerWorking() {
// loopLoginUntilSuccess is another layer of loop that will continuously attempt to
// login to the server until successful.
svr.loopLoginUntilSuccess(20*time.Second, false)
if svr.ctl != nil {
<-svr.ctl.Done()
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl != nil {
<-ctl.Done()
return false, errors.New("control is closed and try another loop")
}
// If the control is nil, it means that the login failed and the service is also closed.
@@ -309,7 +357,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
xl := xlog.FromContextSafe(svr.ctx)
loginFunc := func() (bool, error) {
xl.Infof("尝试连接到服务器...")
xl.Infof("try to connect to server...")
dialer := &controlSessionDialer{
ctx: svr.ctx,
common: svr.common,
@@ -320,7 +368,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
}
sessionCtx, err := dialer.Dial(svr.runID)
if err != nil {
xl.Warnf("连接服务器错误: %v", err)
xl.Warnf("connect to server error: %v", err)
if firstLoginExit {
svr.cancel(cancelErr{Err: err})
}
@@ -331,11 +379,6 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
xl.Infof("login to server success, get run id [%s]", svr.runID)
svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
ctl, err := NewControl(svr.ctx, sessionCtx)
if err != nil {
sessionCtx.Conn.Close()
@@ -345,6 +388,15 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
}
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
// An update must either precede this snapshot or apply to the published
// control. Keep dialing and login outside the reload transaction.
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
ctl.Run(proxyCfgs, visitorCfgs)
// close and replace previous control
svr.ctlMu.Lock()
@@ -367,6 +419,13 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
}
func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
return svr.updateAllConfigurerLocked(proxyCfgs, visitorCfgs)
}
// Caller must hold reloadMu.
func (svr *Service) updateAllConfigurerLocked(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
svr.cfgMu.Lock()
svr.proxyCfgs = proxyCfgs
svr.visitorCfgs = visitorCfgs
@@ -377,7 +436,7 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
svr.ctlMu.RUnlock()
if ctl != nil {
return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
return ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
}
return nil
}
@@ -390,6 +449,15 @@ func (svr *Service) UpdateConfigSource(
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
return svr.updateConfigSourceLocked(common, proxyCfgs, visitorCfgs)
}
// Caller must hold reloadMu.
func (svr *Service) updateConfigSourceLocked(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) error {
cfgSource := svr.configSource
if cfgSource == nil {
return fmt.Errorf("config source is not available")
@@ -417,7 +485,20 @@ func (svr *Service) Close() {
func (svr *Service) GracefulClose(d time.Duration) {
svr.gracefulShutdownDuration.Store(int64(d))
svr.cancel(nil)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
return
}
svr.closed = true
cancel := svr.cancel
svr.lifecycleMu.Unlock()
if cancel != nil {
// Once Run has claimed startup, it also owns cleanup.
cancel(nil)
} else {
svr.stop()
}
}
func (svr *Service) stop() {
@@ -441,6 +522,10 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
if svr.metricsServer != nil {
svr.metricsServer.Close()
svr.metricsServer = nil
}
if svr.vnetController != nil {
_ = svr.vnetController.Stop()
svr.vnetController = nil
@@ -520,7 +605,7 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
}
// Atomically replace the entire configuration
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
if err := svr.updateAllConfigurerLocked(proxies, visitors); err != nil {
return err
}
return nil
+186
View File
@@ -0,0 +1,186 @@
package client
import (
"context"
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"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/msg"
)
func newLifecycleTestService(t *testing.T, creator func(context.Context, *v1.ClientCommonConfig) Connector) (*Service, string) {
t.Helper()
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: new(false),
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: getFreeTCPPort(t)},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: creator,
})
require.NoError(t, err)
_, err = validation.NewConfigValidator(nil).ValidateClientCommonConfig(svr.common)
require.NoError(t, err)
address := svr.webServer.Address()
assertLifecycleAdminPort(t, address, false)
return svr, address
}
func assertLifecycleAdminPort(t *testing.T, address string, released bool) {
t.Helper()
ln, err := net.Listen("tcp", address)
if ln != nil {
_ = ln.Close()
}
if released {
require.NoError(t, err, "admin listener was not released")
} else {
require.Error(t, err, "admin listener was not bound")
}
}
func waitLifecycleSignal(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("service lifecycle operation did not finish")
}
}
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
for _, mode := range []string{"close before Run", "parent already cancelled"} {
t.Run(mode, func(t *testing.T) {
var attempts atomic.Int64
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
attempts.Add(1)
return &failingConnector{err: net.ErrClosed}
})
// Detect startup's global DNS side effect without making DNS requests.
resolver := net.DefaultResolver
t.Cleanup(func() { net.DefaultResolver = resolver })
svr.common.DNSServer = "127.0.0.1:53"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if mode == "close before Run" {
svr.Close()
assertLifecycleAdminPort(t, address, true)
} else {
cancel()
}
done := make(chan struct{})
var runErr error
go func() {
runErr = svr.Run(ctx)
close(done)
}()
waitLifecycleSignal(t, done)
require.ErrorIs(t, runErr, context.Canceled)
require.Zero(t, attempts.Load(), "closed service attempted login")
require.Same(t, resolver, net.DefaultResolver, "closed service changed DNS")
assertLifecycleAdminPort(t, address, true)
})
}
}
func TestServiceStartupShutdownLeavesCleanupToRun(t *testing.T) {
for _, mode := range []string{"Close", "parent cancellation"} {
t.Run(mode, func(t *testing.T) {
entered, resume := make(chan struct{}), make(chan struct{})
svr, address := newLifecycleTestService(t, func(ctx context.Context, _ *v1.ClientCommonConfig) Connector {
close(entered)
<-resume
return &failingConnector{err: ctx.Err()}
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer close(resume)
runDone := make(chan struct{})
go func() { _ = svr.Run(ctx); close(runDone) }()
waitLifecycleSignal(t, entered)
closeDone := make(chan struct{})
go func() {
if mode == "Close" {
svr.Close()
} else {
cancel()
}
close(closeDone)
}()
waitLifecycleSignal(t, closeDone)
select {
case <-runDone:
t.Fatal("Run exited while startup still owned its resources")
default:
}
assertLifecycleAdminPort(t, address, false)
resume <- struct{}{}
waitLifecycleSignal(t, runDone)
assertLifecycleAdminPort(t, address, true)
require.ErrorIs(t, svr.ctx.Err(), context.Canceled)
})
}
}
func TestServiceShutdownAfterLogin(t *testing.T) {
for _, mode := range []string{"Close", "parent cancellation"} {
t.Run(mode, func(t *testing.T) {
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
connector := &testConnector{conn: clientConn}
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
return connector
})
defer svr.Close()
peerDone := make(chan error, 1)
go func() {
rw := msg.NewV1ReadWriter(serverConn)
var login msg.Login
if err := rw.ReadMsgInto(&login); err != nil {
peerDone <- err
return
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "lifecycle-test"}); err != nil {
peerDone <- err
return
}
_, err := io.Copy(io.Discard, serverConn)
peerDone <- err
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runDone := make(chan struct{})
var runErr error
go func() { runErr = svr.Run(ctx); close(runDone) }()
require.Eventually(t, func() bool {
svr.ctlMu.RLock()
defer svr.ctlMu.RUnlock()
return svr.ctl != nil
}, 3*time.Second, time.Millisecond, "login did not establish control")
if mode == "Close" {
svr.Close()
} else {
cancel()
}
waitLifecycleSignal(t, runDone)
require.NoError(t, runErr)
require.True(t, connector.closed.Load())
select {
case err := <-peerDone:
require.NoError(t, err)
case <-time.After(3 * time.Second):
t.Fatal("control connection was not released")
}
assertLifecycleAdminPort(t, address, true)
})
}
}
+232
View File
@@ -0,0 +1,232 @@
// 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 client
import (
"context"
"io"
"net"
"reflect"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
)
// Block proxy construction after the service has read its config snapshot, but
// before Control.Run returns and the replacement control is published.
type handoffProxyConfig struct {
*v1.TCPProxyConfig
started chan struct{}
resume chan struct{}
}
func init() {
proxy.RegisterProxyFactory(reflect.TypeFor[*handoffProxyConfig](), func(base *proxy.BaseProxy, cfg v1.ProxyConfigurer) proxy.Proxy {
blocking := cfg.(*handoffProxyConfig)
close(blocking.started)
<-blocking.resume
return proxy.NewGeneralTCPProxy(base, blocking.TCPProxyConfig)
})
}
func newReloadTestService(t *testing.T, beforeLogin func()) *Service {
t.Helper()
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
Transport: v1.ClientTransportConfig{HeartbeatInterval: -1, HeartbeatTimeout: -1},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
clientConn, serverConn := net.Pipe()
peerDone := make(chan struct{})
t.Cleanup(func() {
_ = clientConn.Close()
_ = serverConn.Close()
waitLifecycleSignal(t, peerDone)
})
go func() {
defer close(peerDone)
rw := msg.NewV1ReadWriter(serverConn)
var login msg.Login
if err := rw.ReadMsgInto(&login); err != nil {
t.Errorf("read login: %v", err)
return
}
if beforeLogin != nil {
beforeLogin()
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "reload-test"}); err != nil {
t.Errorf("write login response: %v", err)
return
}
_, _ = io.Copy(io.Discard, serverConn)
}()
return &testConnector{conn: clientConn}
},
})
require.NoError(t, err)
svr.ctx, svr.cancel = context.WithCancelCause(context.Background())
t.Cleanup(func() {
svr.cancel(nil)
if svr.ctl != nil {
_ = svr.ctl.Close()
waitLifecycleSignal(t, svr.ctl.Done())
}
})
return svr
}
func TestServiceReloadDuringControlHandoff(t *testing.T) {
for _, updateSource := range []bool{false, true} {
name := "direct update"
if updateSource {
name = "config source reload"
}
t.Run(name, func(t *testing.T) {
svr := newReloadTestService(t, nil)
svr.loopLoginUntilSuccess(time.Second, true)
previous := svr.ctl
require.NotNil(t, previous)
require.NoError(t, previous.Close())
waitLifecycleSignal(t, previous.Done())
oldProxy := &handoffProxyConfig{
TCPProxyConfig: &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
RemotePort: 18001,
},
started: make(chan struct{}),
resume: make(chan struct{}),
}
oldVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "old-target",
}}
// Seed the desired config for the next connection without restarting
// proxies on the already closed control.
svr.proxyCfgs = []v1.ProxyConfigurer{oldProxy}
svr.visitorCfgs = []v1.VisitorConfigurer{oldVisitor}
loginDone, updateDone := make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(oldProxy.resume) })
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, loginDone)
})
go func() {
svr.loopLoginUntilSuccess(time.Second, true)
close(loginDone)
}()
waitLifecycleSignal(t, oldProxy.started)
svr.ctlMu.RLock()
current := svr.ctl
svr.ctlMu.RUnlock()
require.Same(t, previous, current, "replacement must still be unpublished")
// Check before starting the updater so it cannot be the lock owner.
unlocked := svr.reloadMu.TryLock()
if unlocked {
svr.reloadMu.Unlock()
}
newProxy := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
RemotePort: 18002,
}
newVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "new-target",
}}
var updateErr error
go func() {
proxies, visitors := []v1.ProxyConfigurer{newProxy}, []v1.VisitorConfigurer{newVisitor}
if updateSource {
updateErr = svr.UpdateConfigSource(svr.common, proxies, visitors)
} else {
updateErr = svr.UpdateAllConfigurer(proxies, visitors)
}
close(updateDone)
}()
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, updateDone)
})
// The gate fixes the handoff window. On the old implementation the
// update completes against the retired control before this gate opens.
select {
case <-updateDone:
t.Error("config update completed before control handoff")
case <-time.After(20 * time.Millisecond):
}
require.False(t, unlocked, "control startup must hold the reload transaction until publication")
resume()
waitLifecycleSignal(t, loginDone)
waitLifecycleSignal(t, updateDone)
require.NoError(t, updateErr)
require.NotSame(t, previous, svr.ctl)
status, ok := svr.getProxyStatus("proxy")
require.True(t, ok)
require.Equal(t, 18002, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
visitor, ok := svr.getVisitorCfg("visitor")
require.True(t, ok)
require.Equal(t, "new-target", visitor.GetBaseConfig().ServerName)
})
}
}
func TestServiceReloadWhileLoginPending(t *testing.T) {
loginStarted, allowLogin, loginDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(allowLogin) })
svr := newReloadTestService(t, func() {
close(loginStarted)
<-allowLogin
})
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, loginDone)
})
go func() {
svr.loopLoginUntilSuccess(time.Second, true)
close(loginDone)
}()
waitLifecycleSignal(t, loginStarted)
proxyCfg := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "during-login", Type: "tcp"},
RemotePort: 18003,
}
updateDone := make(chan struct{})
var updateErr error
go func() {
updateErr = svr.UpdateAllConfigurer([]v1.ProxyConfigurer{proxyCfg}, nil)
close(updateDone)
}()
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, updateDone)
})
// Config updates must complete even while the server has not replied to login.
waitLifecycleSignal(t, updateDone)
require.NoError(t, updateErr)
resume()
waitLifecycleSignal(t, loginDone)
status, ok := svr.getProxyStatus("during-login")
require.True(t, ok)
require.Equal(t, 18003, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
}
+1 -3
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: lo.ToPtr(true),
LoginFailExit: new(true),
WebServer: v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
+1 -1
View File
@@ -25,7 +25,7 @@ import (
"time"
libio "github.com/fatedier/golib/io"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"golang.org/x/time/rate"
+1 -5
View File
@@ -54,11 +54,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
Use: name,
Short: short,
Run: func(cmd *cobra.Command, args []string) {
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
fmt.Println("frpc: the configuration file is not specified")
os.Exit(1)
}
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
fmt.Println(err)
os.Exit(1)
+1 -12
View File
@@ -48,24 +48,13 @@ var natholeDiscoveryCmd = &cobra.Command{
Short: "Discover nathole information from stun server",
RunE: func(cmd *cobra.Command, args []string) error {
// ignore error here, because we can use command line parameters
var cfg *v1.ClientCommonConfig
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
cfg = &v1.ClientCommonConfig{}
if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err)
os.Exit(1)
}
} else {
cfg = loaded
}
} else {
cfg = &v1.ClientCommonConfig{}
if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err)
os.Exit(1)
}
}
if natHoleSTUNServer != "" {
cfg.NatHoleSTUNServer = natHoleSTUNServer
+3 -5
View File
@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "")
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "")
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -141,13 +141,11 @@ func startService(
visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
nodeName string,
tunnelRemark 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, nodeName, tunnelRemark)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
}
+8 -246
View File
@@ -16,11 +16,8 @@ package sub
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -37,28 +34,24 @@ import (
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/banner"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
)
var (
cfgFiles []string
cfgFile string
cfgDir string
showVersion bool
strictConfigMode bool
allowUnsafe []string
authTokens []string
bannerDisplayed bool
)
func init() {
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
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(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
}
@@ -74,30 +67,15 @@ var rootCmd = &cobra.Command{
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
// If authTokens is provided, fetch config from API
if len(authTokens) > 0 {
err := runClientWithTokens(authTokens, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
return nil
}
// 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, unsafeFeatures)
return nil
}
// If multiple config files are specified, run one frpc service for each file
if len(cfgFiles) > 1 {
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
return nil
}
// Do not show command usage here.
err := runClient(cfgFiles[0], unsafeFeatures)
err := runClient(cfgFile, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -127,29 +105,6 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
return err
}
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
var wg sync.WaitGroup
// Display banner first
banner.DisplayBanner()
bannerDisplayed = true
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
for _, cfgFile := range cfgFiles {
wg.Add(1)
// Add a small delay to avoid log output mixing
time.Sleep(100 * time.Millisecond)
go func(path string) {
defer wg.Done()
err := runClient(path, unsafeFeatures)
if err != nil {
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
}
}(cfgFile)
}
wg.Wait()
}
func Execute() {
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
if err := rootCmd.Execute(); err != nil {
@@ -224,7 +179,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
return err
}
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "")
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
}
func startServiceWithAggregator(
@@ -232,25 +187,12 @@ func startServiceWithAggregator(
aggregator *source.Aggregator,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
nodeName string,
tunnelRemark string,
) error {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
// Display banner only once before starting the first service
if !bannerDisplayed {
banner.DisplayBanner()
bannerDisplayed = true
}
// Display node information if available
if nodeName != "" {
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
}
if cfgFile != "" {
log.Infof("启动 frpc 服务 [%s]", cfgFile)
defer log.Infof("frpc 服务 [%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,
@@ -268,183 +210,3 @@ func startServiceWithAggregator(
}
return svr.Run(context.Background())
}
// APIResponse represents the response from LoliaFRP API
type APIResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Config string `json:"config"`
NodeName string `json:"node_name"`
TunnelRemark string `json:"tunnel_remark"`
} `json:"data"`
}
// TokenInfo stores parsed id and token from the -t parameter
type TokenInfo struct {
ID string
Token string
}
func runClientWithTokens(tokens []string, unsafeFeatures *security.UnsafeFeatures) error {
// Parse all tokens (format: id:token)
tokenInfos := make([]TokenInfo, 0, len(tokens))
for _, t := range tokens {
parts := strings.SplitN(t, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid token format '%s', expected 'id:token'", t)
}
tokenInfos = append(tokenInfos, TokenInfo{
ID: strings.TrimSpace(parts[0]),
Token: strings.TrimSpace(parts[1]),
})
}
// Group tokens by token value (same token can have multiple IDs)
tokenToIDs := make(map[string][]string)
for _, ti := range tokenInfos {
tokenToIDs[ti.Token] = append(tokenToIDs[ti.Token], ti.ID)
}
// If we have multiple different tokens, start one service for each token group
if len(tokenToIDs) > 1 {
return runMultipleClientsWithTokens(tokenToIDs, unsafeFeatures)
}
// Get the single token and all its IDs
var token string
var ids []string
for t, idList := range tokenToIDs {
token = t
ids = idList
break
}
return runClientWithTokenAndIDs(token, ids, unsafeFeatures)
}
func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *security.UnsafeFeatures) error {
// Get API server address from environment variable
apiServer := os.Getenv("LOLIA_API")
if apiServer == "" {
apiServer = "https://api.lolia.link"
}
// Build URL with query parameters
url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ","))
// URL is constructed from trusted source (environment variable or hardcoded)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create API request: %v", err)
}
// Carry client version in User-Agent so the API knows which frpc version is requesting
req.Header.Set("User-Agent", version.Full())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch config from API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return fmt.Errorf("failed to decode API response: %v", err)
}
if apiResp.Code != 200 {
return fmt.Errorf("API error: %s", apiResp.Msg)
}
// Decode base64 config
configBytes, err := base64.StdEncoding.DecodeString(apiResp.Data.Config)
if err != nil {
return fmt.Errorf("failed to decode base64 config: %v", err)
}
// Load config directly from bytes
return runClientWithConfig(configBytes, unsafeFeatures, apiResp.Data.NodeName, apiResp.Data.TunnelRemark)
}
func runMultipleClientsWithTokens(tokenToIDs map[string][]string, unsafeFeatures *security.UnsafeFeatures) error {
var wg sync.WaitGroup
// Display banner first
banner.DisplayBanner()
bannerDisplayed = true
log.Infof("检测到 %d 个不同的 token,将并行启动多个 frpc 服务实例", len(tokenToIDs))
index := 0
for token, ids := range tokenToIDs {
wg.Add(1)
currentIndex := index
currentToken := token
currentIDs := ids
totalCount := len(tokenToIDs)
// Add a small delay to avoid log output mixing
time.Sleep(100 * time.Millisecond)
go func() {
defer wg.Done()
maskedToken := currentToken
if len(maskedToken) > 6 {
maskedToken = maskedToken[:3] + "***" + maskedToken[len(maskedToken)-3:]
} else {
maskedToken = "***"
}
log.Infof("[%d/%d] 启动 token: %s (IDs: %v)", currentIndex+1, totalCount, maskedToken, currentIDs)
err := runClientWithTokenAndIDs(currentToken, currentIDs, unsafeFeatures)
if err != nil {
fmt.Printf("\nToken [%s] 启动失败: %v\n", maskedToken, err)
}
}()
index++
}
wg.Wait()
return nil
}
func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeatures, nodeName, tunnelRemark string) error {
// Render template first
renderedBytes, err := config.RenderWithTemplate(configBytes, config.GetValues())
if err != nil {
return fmt.Errorf("failed to render template: %v", err)
}
var allCfg v1.ClientConfig
if err := config.LoadConfigure(renderedBytes, &allCfg, strictConfigMode); err != nil {
return fmt.Errorf("failed to parse config: %v", err)
}
cfg := &allCfg.ClientCommonConfig
proxyCfgs := make([]v1.ProxyConfigurer, 0, len(allCfg.Proxies))
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
visitorCfgs := make([]v1.VisitorConfigurer, 0, len(allCfg.Visitors))
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
// Call Complete to fill in default values
if err := cfg.Complete(); err != nil {
return fmt.Errorf("failed to complete config: %v", err)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
if err != nil {
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, "", nodeName, tunnelRemark)
}
+1 -2
View File
@@ -45,12 +45,11 @@ var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify that the configures is valid",
RunE: func(cmd *cobra.Command, args []string) error {
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
if cfgFile == "" {
fmt.Println("frpc: the configuration file is not specified")
return nil
}
cfgFile := cfgFiles[0]
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
if warning != nil {
+19 -37
View File
@@ -79,9 +79,22 @@ webServer.password = "admin"
# Admin assets directory. By default, these assets are bundled with frpc.
# webServer.assetsDir = "./static"
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
# Enable golang pprof handlers in admin listener.
webServer.pprofEnable = false
# Separate health and optional profiling listener. frpc does not serve /metrics.
# Port 0 disables it. The default address only accepts local connections.
metricsServer.addr = "127.0.0.1"
metricsServer.port = 0
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
# metricsServer.user = "monitor"
# metricsServer.password = "change-me"
# metricsServer.tls.certFile = "metrics.crt"
# metricsServer.tls.keyFile = "metrics.key"
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
metricsServer.pprofEnable = false
# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.
# transport.dialServerTimeout = 10
@@ -131,6 +144,12 @@ transport.tls.enable = true
# transport.tls.trustedCaFile = "ca.crt"
# transport.tls.serverName = "example.com"
# If useSystemCa is true, frpc verifies the server certificate using system CAs.
# If trustedCaFile is also set, its CA certificates are added to the system CA pool.
# Only supported in frpc transport.tls; requires tls.enable = true unless protocol is wss.
# CA loading or certificate verification failures cause the connection to fail.
# transport.tls.useSystemCa = false
# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the
# first custom byte when tls is enabled.
# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.
@@ -275,10 +294,6 @@ localIP = "127.0.0.1"
localPort = 8000
subdomain = "web02"
customDomains = ["web02.yourdomain.com"]
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
# on the same domain always takes precedence over the redirect.
# httpRedirect = true
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
# v1 or v2 or empty
transport.proxyProtocolVersion = "v2"
@@ -340,14 +355,6 @@ type = "https2http"
localAddr = "127.0.0.1:80"
crtPath = "./server.crt"
keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp"
@@ -360,14 +367,6 @@ type = "https2https"
localAddr = "127.0.0.1:443"
crtPath = "./server.crt"
keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp"
@@ -381,15 +380,6 @@ localAddr = "127.0.0.1:443"
hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp"
[[proxies]]
name = "plugin_http2https_redirect"
type = "http"
customDomains = ["test.yourdomain.com"]
[proxies.plugin]
type = "http2https_redirect"
# Optional. Defaults to 443. Set this if the HTTPS entry is exposed on a non-standard port.
# httpsPort = 443
[[proxies]]
name = "plugin_http2http"
type = "tcp"
@@ -409,14 +399,6 @@ type = "tls2raw"
localAddr = "127.0.0.1:80"
crtPath = "./server.crt"
keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
[[proxies]]
name = "secret_tcp"
+15 -13
View File
@@ -40,12 +40,6 @@ transport.maxPoolCount = 5
# If negative, keep-alive probes are disabled.
# transport.tcpKeepalive = 7200
# proxyIdleTimeout specifies the maximum time in seconds that a proxied user connection
# can stay open without any traffic in either direction before frps closes it.
# It reclaims connections whose endpoints are still open at the TCP level but will never send data again.
# By default, this value is 0, which disables the idle timeout.
# transport.proxyIdleTimeout = 7200
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
transport.tls.force = false
@@ -58,12 +52,6 @@ transport.tls.force = false
vhostHTTPPort = 80
vhostHTTPSPort = 443
# Port used in the Location header when redirecting HTTP requests to HTTPS for
# proxies with httpRedirect enabled. Set it when browsers reach the HTTPS vhost
# through a port mapping, so it differs from vhostHTTPSPort.
# By default, this value is vhostHTTPSPort.
# vhostHTTPSRedirectPort = 443
# Response header timeout(seconds) for vhost http server, default is 60s
# vhostHTTPTimeout = 60
@@ -87,13 +75,27 @@ webServer.password = "admin"
# dashboard assets directory(only for debug mode)
# webServer.assetsDir = "./static"
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
# Enable golang pprof handlers in dashboard listener.
# Dashboard port must be set first
webServer.pprofEnable = false
# enablePrometheus will export prometheus metrics on webServer in /metrics api.
# Deprecated: enablePrometheus exports /metrics on webServer only.
# Use metricsServer.port instead; it enables business metrics automatically.
enablePrometheus = true
# Separate health, Prometheus metrics, and optional profiling listener.
# Port 0 disables it. The default address only accepts local connections.
metricsServer.addr = "127.0.0.1"
metricsServer.port = 0
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
# metricsServer.user = "monitor"
# metricsServer.password = "change-me"
# metricsServer.tls.certFile = "metrics.crt"
# metricsServer.tls.keyFile = "metrics.key"
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
metricsServer.pprofEnable = false
# console or real logFile path like ./frps.log
log.to = "./frps.log"
# trace, debug, info, warn, error
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+9 -7
View File
@@ -1,14 +1,16 @@
FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frpc/ /web/frpc/
WORKDIR /web
RUN npm install
WORKDIR /web/frpc
RUN npm run build
COPY web/package.json web/package-lock.json ./
COPY web/shared/package.json ./shared/package.json
COPY web/frpc/package.json ./frpc/package.json
COPY web/frps/package.json ./frps/package.json
RUN npm ci
COPY web/shared/ ./shared/
COPY web/frpc/ ./frpc/
RUN npm run build --workspace frpc
FROM golang:1.25 AS building
FROM golang:1.26 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
+9 -7
View File
@@ -1,14 +1,16 @@
FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frps/ /web/frps/
WORKDIR /web
RUN npm install
WORKDIR /web/frps
RUN npm run build
COPY web/package.json web/package-lock.json ./
COPY web/shared/package.json ./shared/package.json
COPY web/frpc/package.json ./frpc/package.json
COPY web/frps/package.json ./frps/package.json
RUN npm ci
COPY web/shared/ ./shared/
COPY web/frps/ ./frps/
RUN npm run build --workspace frps
FROM golang:1.25 AS building
FROM golang:1.26 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
+8 -29
View File
@@ -1,17 +1,15 @@
module github.com/fatedier/frp
go 1.25.0
go 1.26.0
require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/coreos/go-oidc/v3 v3.18.0
github.com/fatedier/golib v0.8.1
github.com/fatedier/golib v0.8.2
github.com/fatedier/yamux v0.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/yamux v0.1.1
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.36.3
github.com/pelletier/go-toml/v2 v2.2.0
@@ -27,8 +25,8 @@ 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.54.0
golang.org/x/net v0.56.0
golang.org/x/crypto v0.56.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
@@ -41,17 +39,11 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
@@ -60,27 +52,20 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/klauspost/reedsolomon v1.12.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/templexxx/cpu v0.1.1 // indirect
github.com/templexxx/xorsimd v0.4.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.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
@@ -88,9 +73,3 @@ require (
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
// Use the Lolia-FRP fork of golib: io.Join relays with adaptively sized buffers.
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.0.0-20260810035216-6e2252e33564
+16 -45
View File
@@ -1,30 +1,14 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Lolia-FRP/golib v0.0.0-20260810035216-6e2252e33564 h1:L9XoKV/oAoTJNe/h93oNPlhMf6EsM0V54/tpucjbz1A=
github.com/Lolia-FRP/golib v0.0.0-20260810035216-6e2252e33564/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
@@ -36,12 +20,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -86,15 +70,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
@@ -122,9 +99,8 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -164,8 +140,6 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
@@ -177,11 +151,9 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
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=
@@ -191,8 +163,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -207,7 +179,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -215,8 +186,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -224,8 +195,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
+1 -1
View File
@@ -18,7 +18,7 @@ rm -rf ./release/packages
mkdir -p ./release/packages
os_all='linux windows darwin freebsd openbsd android'
arch_all='amd64 arm arm64'
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
extra_all='_ hf'
cd ./release
+1 -1
View File
@@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
}
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
+6 -8
View File
@@ -17,8 +17,6 @@ package legacy
import (
"strings"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
out.Transport.ProxyURL = conf.HTTPProxy
out.Transport.PoolCount = conf.PoolCount
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.Protocol = conf.Protocol
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
@@ -57,8 +55,8 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.Enable = new(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.CertFile = conf.TLSCertFile
out.Transport.TLS.KeyFile = conf.TLSKeyFile
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
@@ -77,7 +75,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.WebServer.PprofEnable = conf.PprofEnable
out.DNSServer = conf.DNSServer
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
out.LoginFailExit = new(conf.LoginFailExit)
out.Start = conf.Start
out.UDPPacketSize = conf.UDPPacketSize
out.Metadatas = conf.Metas
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
out.Log.MaxDays = conf.LogMaxDays
out.Log.DisablePrintColor = conf.DisableLogColor
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
out.SubDomainHost = conf.SubDomainHost
out.Custom404Page = conf.Custom404Page
out.UserConnTimeout = conf.UserConnTimeout
out.UDPPacketSize = conf.UDPPacketSize
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
out.Transport.MaxPoolCount = conf.MaxPoolCount
+7 -6
View File
@@ -49,8 +49,7 @@ type ClientCommonConfig struct {
// STUN server to help penetrate NAT hole.
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
// DNSServer specifies a DNS server address for FRPC to use. If this value
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
// supported, e.g. "https://1.1.1.1/dns-query".
// is "", the default DNS will be used.
DNSServer string `json:"dnsServer,omitempty"`
// LoginFailExit controls whether or not the client should exit after a
// failed login attempt. If false, the client will retry until a login
@@ -63,6 +62,7 @@ type ClientCommonConfig struct {
Log LogConfig `json:"log,omitempty"`
WebServer WebServerConfig `json:"webServer,omitempty"`
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
Transport ClientTransportConfig `json:"transport,omitempty"`
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
@@ -86,7 +86,7 @@ type ClientCommonConfig struct {
func (c *ClientCommonConfig) Complete() error {
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, new(true))
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
if err := c.Auth.Complete(); err != nil {
@@ -95,6 +95,7 @@ func (c *ClientCommonConfig) Complete() error {
c.Log.Complete()
c.Transport.Complete()
c.WebServer.Complete()
c.MetricsServer.Complete()
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
return nil
@@ -152,7 +153,7 @@ func (c *ClientTransportConfig) Complete() {
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
if lo.FromPtr(c.TCPMux) {
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.
@@ -181,8 +182,8 @@ type TLSClientConfig struct {
}
func (c *TLSClientConfig) Complete() {
c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))
c.Enable = util.EmptyOr(c.Enable, new(true))
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, new(true))
}
type AuthClientConfig struct {
+24
View File
@@ -72,6 +72,26 @@ func (c *WebServerConfig) Complete() {
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
}
// MetricsServerConfig configures a separate listener for health checks, metrics
// (frps only), and optional pprof handlers. It does not serve the web interface.
type MetricsServerConfig struct {
// Addr defaults to "127.0.0.1".
Addr string `json:"addr,omitempty"`
// Port disables the listener when set to 0.
Port int `json:"port,omitempty"`
// User and Password protect metrics and pprof; health checks remain anonymous.
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
// PprofEnable explicitly enables profiling, excluding cmdline.
PprofEnable bool `json:"pprofEnable,omitempty"`
// Enable TLS if TLS is not nil.
TLS *TLSConfig `json:"tls,omitempty"`
}
func (c *MetricsServerConfig) Complete() {
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
}
type TLSConfig struct {
// CertFile specifies the path of the cert file that client will load.
CertFile string `json:"certFile,omitempty"`
@@ -79,6 +99,10 @@ type TLSConfig struct {
KeyFile string `json:"keyFile,omitempty"`
// TrustedCaFile specifies the path of the trusted ca file that will load.
TrustedCaFile string `json:"trustedCaFile,omitempty"`
// UseSystemCa enables server certificate verification using system CAs.
// If TrustedCaFile is also set, its certificates are added to the system CA pool.
// Only supported in frpc's transport.tls. With neither trust source, frpc skips verification.
UseSystemCa bool `json:"useSystemCa,omitempty"`
// ServerName specifies the custom server name of tls certificate. By
// default, server name if same to ServerAddr.
ServerName string `json:"serverName,omitempty"`
-8
View File
@@ -369,12 +369,6 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
ProxyBaseConfig
DomainConfig
// HTTPRedirect requests frps to redirect plain HTTP requests for the
// proxy's domains to their HTTPS endpoint. It only takes effect when
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
// proxy registered on the same domain.
HTTPRedirect bool `json:"httpRedirect,omitempty"`
}
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
@@ -382,7 +376,6 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
m.CustomDomains = c.CustomDomains
m.SubDomain = c.SubDomain
m.HTTPRedirect = c.HTTPRedirect
}
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
@@ -390,7 +383,6 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.CustomDomains = m.CustomDomains
c.SubDomain = m.SubDomain
c.HTTPRedirect = m.HTTPRedirect
}
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
+2 -51
View File
@@ -16,9 +16,6 @@ package v1
import (
"reflect"
"slices"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
@@ -26,7 +23,6 @@ import (
const (
PluginHTTP2HTTPS = "http2https"
PluginHTTP2HTTPSRedirect = "http2https_redirect"
PluginHTTPProxy = "http_proxy"
PluginHTTPS2HTTP = "https2http"
PluginHTTPS2HTTPS = "https2https"
@@ -40,7 +36,6 @@ const (
var clientPluginOptionsTypeMap = map[string]reflect.Type{
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
@@ -83,29 +78,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.ClientPluginOptions)
}
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
// that terminate TLS locally.
type AutoTLSOptions struct {
Enable bool `json:"enable,omitempty"`
// Contact email for certificate expiration and important notices.
Email string `json:"email,omitempty"`
// Directory used to cache ACME account and certificates.
CacheDir string `json:"cacheDir,omitempty"`
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
CADirURL string `json:"caDirURL,omitempty"`
// Restrict certificate issuance to the listed domains.
HostAllowList []string `json:"hostAllowList,omitempty"`
}
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
if o == nil {
return nil
}
out := *o
out.HostAllowList = slices.Clone(o.HostAllowList)
return &out
}
type HTTP2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -124,21 +96,6 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
return &out
}
type HTTP2HTTPSRedirectPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPSPort int `json:"httpsPort,omitempty"`
}
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"`
@@ -163,11 +120,10 @@ type HTTPS2HTTPPluginOptions struct {
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
}
func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
}
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
@@ -177,7 +133,6 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
@@ -189,11 +144,10 @@ type HTTPS2HTTPSPluginOptions struct {
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
}
func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
}
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
@@ -203,7 +157,6 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
@@ -279,7 +232,6 @@ type TLS2RawPluginOptions struct {
LocalAddr string `json:"localAddr,omitempty"`
CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
}
func (o *TLS2RawPluginOptions) Complete() {}
@@ -289,7 +241,6 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
return nil
}
out := *o
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
+4 -15
View File
@@ -51,12 +51,6 @@ type ServerConfig struct {
// Vhost requests. If this value is 0, the server will not listen for HTTPS
// requests.
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
// VhostHTTPSRedirectPort specifies the port used in the Location header
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
// enabled. Set it when browsers reach the HTTPS vhost through a port
// mapping, so it differs from VhostHTTPSPort. By default, this value is
// VhostHTTPSPort.
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
// requests on one single port. If it's not - it will listen on this value for
@@ -76,6 +70,7 @@ type ServerConfig struct {
SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`
WebServer WebServerConfig `json:"webServer,omitempty"`
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
// EnablePrometheus will export prometheus metrics on webserver address
// in /metrics api.
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
@@ -111,6 +106,7 @@ func (c *ServerConfig) Complete() error {
c.Log.Complete()
c.Transport.Complete()
c.WebServer.Complete()
c.MetricsServer.Complete()
c.SSHTunnelGateway.Complete()
c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0")
@@ -124,8 +120,7 @@ func (c *ServerConfig) Complete() error {
}
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, new(true))
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
@@ -180,12 +175,6 @@ type ServerTransportConfig struct {
// before terminating the connection. It is not recommended to change this
// value. By default, this value is 90. Set negative value to disable it.
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
// ProxyIdleTimeout specifies the maximum time in seconds that a proxied
// user connection can stay open without any traffic in either direction
// before frps closes it. This reclaims connections whose endpoints are
// still open at the TCP level but will never send data again. By default,
// this value is 0, which disables the idle timeout.
ProxyIdleTimeout int64 `json:"proxyIdleTimeout,omitempty"`
// QUIC options.
QUIC QUICOptions `json:"quic,omitempty"`
// TLS specifies TLS settings for the connection from the client.
@@ -193,7 +182,7 @@ type ServerTransportConfig struct {
}
func (c *ServerTransportConfig) Complete() {
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
+5
View File
@@ -38,6 +38,7 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
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 nil, validateMetricsServerConfig(&c.MetricsServer, &c.WebServer) },
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
}
@@ -163,6 +164,10 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
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))
// wss keeps TLS on regardless of transport.tls.enable.
if c.TLS.UseSystemCa && c.Protocol != "wss" {
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa requires transport.tls.enable to be true unless transport.protocol is wss"))
}
}
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
+24
View File
@@ -23,6 +23,9 @@ import (
func validateWebServerConfig(c *v1.WebServerConfig) error {
if c.TLS != nil {
if c.TLS.UseSystemCa {
return fmt.Errorf("webServer.tls.useSystemCa is only supported in frpc transport.tls")
}
if c.TLS.CertFile == "" {
return fmt.Errorf("tls.certFile must be specified when tls is enabled")
}
@@ -34,6 +37,27 @@ func validateWebServerConfig(c *v1.WebServerConfig) error {
return ValidatePort(c.Port, "webServer.port")
}
func validateMetricsServerConfig(c *v1.MetricsServerConfig, web *v1.WebServerConfig) error {
if err := ValidatePort(c.Port, "metricsServer.port"); err != nil {
return err
}
if c.TLS != nil {
if c.TLS.UseSystemCa {
return fmt.Errorf("metricsServer.tls.useSystemCa is only supported in frpc transport.tls")
}
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
return fmt.Errorf("metricsServer.tls.certFile and metricsServer.tls.keyFile must both be specified when tls is enabled")
}
}
// Only reject an exact duplicate. Whether two different addresses can share
// a port depends on the address family and the platform, so leave it to
// net.Listen to decide.
if c.Port > 0 && web.Port > 0 && c.Port == web.Port && c.Addr == web.Addr {
return fmt.Errorf("metricsServer.addr:port conflicts with webServer.addr:port")
}
return nil
}
// ValidatePort checks that the network port is in range
func ValidatePort(port int, fieldPath string) error {
if 0 <= port && port <= 65535 {
@@ -0,0 +1,119 @@
// 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 validation
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateMetricsServerConfig(t *testing.T) {
for _, tc := range []struct {
name string
metrics v1.MetricsServerConfig
web v1.WebServerConfig
wantErr string
}{
{name: "disabled"},
{name: "negative port", metrics: v1.MetricsServerConfig{Port: -1}, wantErr: "metricsServer.port"},
{name: "overflow port", metrics: v1.MetricsServerConfig{Port: 65536}, wantErr: "metricsServer.port"},
{name: "maximum port", metrics: v1.MetricsServerConfig{Port: 65535}},
{name: "empty TLS", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{}}, wantErr: "metricsServer.tls.certFile"},
{name: "only certificate", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert"}}, wantErr: "metricsServer.tls.keyFile"},
{name: "only key", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{KeyFile: "key"}}, wantErr: "metricsServer.tls.certFile"},
{name: "TLS pair", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert", KeyFile: "key"}}},
{name: "system CA", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{UseSystemCa: true}}, wantErr: "metricsServer.tls.useSystemCa"},
{
name: "same address", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
web: v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, wantErr: "conflicts with webServer",
},
{
name: "different addresses", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
web: v1.WebServerConfig{Addr: "127.0.0.2", Port: 7501},
},
{
name: "different ports", metrics: v1.MetricsServerConfig{Port: 7501},
web: v1.WebServerConfig{Port: 7500},
},
{
name: "web disabled", metrics: v1.MetricsServerConfig{Port: 7501},
},
{
name: "metrics disabled", web: v1.WebServerConfig{Port: 7501},
},
} {
t.Run(tc.name, func(t *testing.T) {
checkMetricsConfigBoth(t, tc.metrics, tc.web, tc.wantErr)
})
}
// Two different addresses on the same port may or may not coexist depending
// on the address family and the platform, so only an exact duplicate is a
// configuration error here.
for _, wildcard := range []string{"", "0.0.0.0", "::"} {
t.Run("wildcard "+wildcard+" with loopback", func(t *testing.T) {
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, "")
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "")
})
t.Run("same wildcard "+wildcard, func(t *testing.T) {
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "conflicts with webServer")
})
}
t.Run("different wildcards on the same port", func(t *testing.T) {
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "0.0.0.0", Port: 7501},
v1.WebServerConfig{Addr: "::", Port: 7501}, "")
})
}
func checkMetricsConfigBoth(t *testing.T, metrics v1.MetricsServerConfig, web v1.WebServerConfig, wantErr string) {
t.Helper()
server := &v1.ServerConfig{}
client := &v1.ClientCommonConfig{}
require.NoError(t, server.Complete())
require.NoError(t, client.Complete())
// Assign after completion to also check raw empty (wildcard) addresses.
server.MetricsServer, server.WebServer = metrics, web
client.MetricsServer, client.WebServer = metrics, web
validator := NewConfigValidator(nil)
_, serverErr := validator.ValidateServerConfig(server)
_, clientErr := validator.ValidateClientCommonConfig(client)
for _, err := range []error{serverErr, clientErr} {
if wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, wantErr)
require.ErrorContains(t, err, "metricsServer")
}
}
}
func TestMetricsServerDefaults(t *testing.T) {
for _, port := range []int{0, 7501} {
server := &v1.ServerConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
client := &v1.ClientCommonConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
require.NoError(t, server.Complete())
require.NoError(t, client.Complete())
for _, cfg := range []v1.MetricsServerConfig{server.MetricsServer, client.MetricsServer} {
require.Equal(t, "127.0.0.1", cfg.Addr)
require.Equal(t, port, cfg.Port)
require.False(t, cfg.PprofEnable)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
// 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 validation
import (
"fmt"
"unicode"
"unicode/utf8"
)
const (
// MaxRunIDLength is the maximum number of bytes accepted for a control run ID.
MaxRunIDLength = 64
)
func validateIdentifier(value, kind string, maxLength int) error {
if value == "" {
return fmt.Errorf("%s cannot be empty", kind)
}
if len(value) > maxLength {
return fmt.Errorf("%s is too long: length %d exceeds maximum %d", kind, len(value), maxLength)
}
if !utf8.ValidString(value) {
return fmt.Errorf("%s must be valid UTF-8", kind)
}
for _, r := range value {
if !unicode.IsPrint(r) {
return fmt.Errorf("%s contains non-printable character", kind)
}
}
return nil
}
func ValidateRunID(runID string) error {
return validateIdentifier(runID, "run id", MaxRunIDLength)
}
+48
View File
@@ -0,0 +1,48 @@
// 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 validation
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateIdentifiers(t *testing.T) {
tests := []struct {
name string
validate func(string) error
value string
wantError string
}{
{name: "run id accepts printable values", validate: ValidateRunID, value: "run-%1000s-中文"},
{name: "run id rejects empty", validate: ValidateRunID, wantError: "cannot be empty"},
{name: "run id rejects control character", validate: ValidateRunID, value: "run\nforged", wantError: "non-printable"},
{name: "run id rejects invalid utf8", validate: ValidateRunID, value: string([]byte{0xff}), wantError: "valid UTF-8"},
{name: "run id rejects excessive length", validate: ValidateRunID, value: strings.Repeat("a", MaxRunIDLength+1), wantError: "too long"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.validate(tt.value)
if tt.wantError == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantError)
})
}
}
-38
View File
@@ -16,8 +16,6 @@ package validation
import (
"errors"
"fmt"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -26,8 +24,6 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
switch v := c.(type) {
case *v1.HTTP2HTTPSPluginOptions:
return validateHTTP2HTTPSPluginOptions(v)
case *v1.HTTP2HTTPSRedirectPluginOptions:
return validateHTTP2HTTPSRedirectPluginOptions(v)
case *v1.HTTPS2HTTPPluginOptions:
return validateHTTPS2HTTPPluginOptions(v)
case *v1.HTTPS2HTTPSPluginOptions:
@@ -49,17 +45,10 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
return nil
}
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
return ValidatePort(c.HTTPSPort, "httpsPort")
}
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
if c.LocalAddr == "" {
return errors.New("localAddr is required")
}
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
@@ -67,9 +56,6 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
if c.LocalAddr == "" {
return errors.New("localAddr is required")
}
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
@@ -91,29 +77,5 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
if c.LocalAddr == "" {
return errors.New("localAddr is required")
}
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
if c == nil || !c.Enable {
return nil
}
if crtPath != "" || keyPath != "" {
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
}
if strings.TrimSpace(c.CacheDir) == "" {
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
}
if len(c.HostAllowList) > 0 {
for _, host := range c.HostAllowList {
if strings.TrimSpace(host) == "" {
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
}
}
}
return nil
}
+6
View File
@@ -44,6 +44,12 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
if err := validateWebServerConfig(&c.WebServer); err != nil {
errs = AppendError(errs, err)
}
if err := validateMetricsServerConfig(&c.MetricsServer, &c.WebServer); err != nil {
errs = AppendError(errs, err)
}
if c.Transport.TLS.UseSystemCa {
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa is only supported in frpc transport.tls"))
}
errs = AppendError(errs, ValidatePort(c.BindPort, "bindPort"))
errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort"))
+146
View File
@@ -0,0 +1,146 @@
// 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 validation
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestClientTLSCATrustSources(t *testing.T) {
for _, test := range []struct {
name string
tlsJSON string
useSystemCa bool
caFile string
}{
{name: "default", tlsJSON: `{}`},
{name: "file only", tlsJSON: `{"trustedCaFile":"ca.pem"}`, caFile: "ca.pem"},
{name: "system only", tlsJSON: `{"useSystemCa":true}`, useSystemCa: true},
{name: "combined", tlsJSON: `{"useSystemCa":true,"trustedCaFile":"ca.pem"}`, useSystemCa: true, caFile: "ca.pem"},
} {
t.Run(test.name, func(t *testing.T) {
cfg := &v1.ClientCommonConfig{}
require.NoError(t, json.Unmarshal([]byte(`{"transport":{"tls":`+test.tlsJSON+`}}`), cfg))
require.NoError(t, cfg.Complete())
require.Equal(t, test.useSystemCa, cfg.Transport.TLS.UseSystemCa)
require.Equal(t, test.caFile, cfg.Transport.TLS.TrustedCaFile)
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
require.NoError(t, warnings)
require.NoError(t, err)
})
}
}
func TestClientSystemCARequiresTLS(t *testing.T) {
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
for _, state := range []struct {
name string
enable *bool
}{
{name: "default"},
{name: "enabled", enable: new(true)},
{name: "disabled", enable: new(false)},
} {
t.Run(protocol+"/"+state.name, func(t *testing.T) {
cfg := &v1.ClientCommonConfig{}
cfg.Transport.Protocol = protocol
cfg.Transport.TLS.Enable = state.enable
cfg.Transport.TLS.UseSystemCa = true
require.NoError(t, cfg.Complete())
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
require.NoError(t, warnings, "useSystemCa must produce an error, not a warning")
if state.name == "disabled" && protocol != "wss" {
require.ErrorContains(t, err, "transport.tls.useSystemCa")
require.ErrorContains(t, err, "transport.tls.enable")
} else {
require.NoError(t, err)
}
})
}
}
}
func TestDisabledTLSFileWarnings(t *testing.T) {
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
t.Run(protocol, func(t *testing.T) {
cfg := &v1.ClientCommonConfig{}
cfg.Transport.Protocol = protocol
cfg.Transport.TLS.Enable = new(false)
cfg.Transport.TLS.CertFile = "client.pem"
cfg.Transport.TLS.KeyFile = "client.key"
cfg.Transport.TLS.TrustedCaFile = "ca.pem"
require.NoError(t, cfg.Complete())
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
require.NoError(t, err)
for _, field := range []string{"certFile", "keyFile", "trustedCaFile"} {
require.ErrorContains(t, warnings, "transport.tls."+field+" is invalid when transport.tls.enable is false")
}
})
}
}
func TestSystemCAUnsupportedLocations(t *testing.T) {
for _, test := range []struct {
name string
server bool
configJSON string
fieldPath string
}{
{
name: "frps transport", server: true,
configJSON: `{"transport":{"tls":{"useSystemCa":%t}}}`, fieldPath: "transport.tls.useSystemCa",
},
{
name: "frpc webServer", configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
fieldPath: "webServer.tls.useSystemCa",
},
{
name: "frps webServer", server: true,
configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
fieldPath: "webServer.tls.useSystemCa",
},
} {
for _, enabled := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%t", test.name, enabled), func(t *testing.T) {
data := fmt.Appendf(nil, test.configJSON, enabled)
var warnings Warning
var err error
if test.server {
cfg := &v1.ServerConfig{}
require.NoError(t, json.Unmarshal(data, cfg))
require.NoError(t, cfg.Complete())
warnings, err = NewConfigValidator(nil).ValidateServerConfig(cfg)
} else {
cfg := &v1.ClientCommonConfig{}
require.NoError(t, json.Unmarshal(data, cfg))
require.NoError(t, cfg.Complete())
warnings, err = NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
}
require.NoError(t, warnings)
if enabled {
require.EqualError(t, err, test.fieldPath+" is only supported in frpc transport.tls")
} else {
require.NoError(t, err)
}
})
}
}
}
+2 -8
View File
@@ -253,9 +253,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
m.mu.Lock()
defer m.mu.Unlock()
filterAll := proxyType == "" || proxyType == "all"
for name, proxyStats := range m.info.ProxyStatistics {
if !filterAll && proxyStats.ProxyType != proxyType {
if proxyStats.ProxyType != proxyType {
continue
}
res = append(res, toProxyStats(name, proxyStats))
@@ -268,14 +267,9 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
// filterAll allows the "all proxies" API to look up a proxy by name without
// knowing its type (proxyType == "" or "all").
filterAll := proxyType == "" || proxyType == "all"
if filterAll || proxyStats.ProxyType == proxyType {
if ok && proxyStats.ProxyType == proxyType {
res = toProxyStats(proxyName, proxyStats)
}
}
return
}
+55 -4
View File
@@ -19,6 +19,8 @@ import (
"io"
"net"
"reflect"
"sync"
"time"
"github.com/fatedier/frp/pkg/proto/wire"
)
@@ -111,14 +113,22 @@ type Dispatcher struct {
sendCh chan Message
doneCh chan struct{}
stopCh chan struct{}
stopOnce sync.Once
msgHandlers map[reflect.Type]func(Message)
}
// NewDispatcher uses SetReadDeadline, when supported, and Close, when rw
// implements io.Closer, to interrupt ReadMsg after a write failure. Without a
// working read deadline, Close or the caller must arrange for ReadMsg to return.
// These methods must return in bounded time and must not wait for Done: Done
// closes only after ReadMsg, any synchronous handler, and stop have returned.
func NewDispatcher(rw ReadWriter) *Dispatcher {
return &Dispatcher{
rw: rw,
sendCh: make(chan Message, 100),
doneCh: make(chan struct{}),
stopCh: make(chan struct{}),
msgHandlers: make(map[reflect.Type]func(Message)),
}
}
@@ -129,38 +139,79 @@ func (d *Dispatcher) Run() {
go d.readLoop()
}
// stop rejects new sends and interrupts ReadMsg when supported by rw. Done remains
// owned by readLoop so the control owner cannot clean up while a handler is running.
func (d *Dispatcher) stop() {
d.stopOnce.Do(func() {
close(d.stopCh)
if conn, ok := d.rw.(interface{ SetReadDeadline(time.Time) error }); ok {
_ = conn.SetReadDeadline(time.Now())
}
if closer, ok := d.rw.(io.Closer); ok {
_ = closer.Close()
}
})
}
func (d *Dispatcher) sendLoop() {
for {
select {
case <-d.doneCh:
case <-d.stopCh:
return
case m := <-d.sendCh:
_ = d.rw.WriteMsg(m)
if err := d.rw.WriteMsg(m); err != nil {
d.stop()
return
}
}
}
}
func (d *Dispatcher) readLoop() {
defer close(d.doneCh)
defer d.stop()
for {
m, err := d.rw.ReadMsg()
if err != nil {
close(d.doneCh)
return
}
select {
case <-d.stopCh:
return
default:
}
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
handler(m)
}
}
}
// Send queues m for writing. A nil error does not guarantee delivery.
func (d *Dispatcher) Send(m Message) error {
select {
case <-d.doneCh:
case <-d.stopCh:
return io.EOF
default:
}
return d.enqueue(m)
}
func (d *Dispatcher) enqueue(m Message) error {
select {
case <-d.stopCh:
return io.EOF
case d.sendCh <- m:
// Both cases can be ready if shutdown races with enqueueing. Do not
// report success when shutdown is already observable after the send.
select {
case <-d.stopCh:
return io.EOF
default:
return nil
}
}
}
func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
+82
View File
@@ -0,0 +1,82 @@
// 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 msg
import (
"bytes"
"errors"
"io"
"testing"
)
// Preserve the original exported constructor function type.
var _ func(ReadWriter) *Dispatcher = NewDispatcher
func TestDispatcherEnqueueRejectsStoppedDispatcher(t *testing.T) {
c := newRegressionControl(t)
d := NewDispatcher(c)
d.stop()
// Model shutdown after Send's initial check: the stop and buffered send
// cases are both ready. A send selected here must still return EOF.
if err := d.enqueue(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("enqueue racing with shutdown: %v, want EOF", err)
}
// This single call checks that a stopped dispatcher's enqueue returns EOF; it
// does not guarantee exercising the send case and post-enqueue check on every run.
if len(d.sendCh) > 0 {
t.Log("selected the send case; post-enqueue check returned EOF")
}
}
func TestDispatcherAcceptsReadWriterWithoutClose(t *testing.T) {
t.Run("codec factory", func(t *testing.T) {
d := NewDispatcher(NewV1ReadWriter(&bytes.Buffer{}))
d.Run()
regressionAwait(t, d.Done(), "non-closable codec EOF")
})
t.Run("write failure", func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
// Hide Close to model an existing caller implementing only ReadWriter.
d := NewDispatcher(struct{ ReadWriter }{c})
d.Run()
t.Cleanup(func() {
c.transport.close()
regressionAwait(t, d.Done(), "externally interrupted reader")
})
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("injected write failure: %v", err)
}
regressionAwait(t, d.stopCh, "write failure stops sends")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after write failure: %v, want EOF", err)
}
if c.closeCalls.Load() != 0 {
t.Fatal("dispatcher closed a connection hidden by ReadWriter")
}
select {
case <-d.Done():
t.Fatal("Done closed before the non-closable reader returned")
default:
}
})
}
+383
View File
@@ -0,0 +1,383 @@
// 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 msg
import (
"bytes"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
libcrypto "github.com/fatedier/golib/crypto"
)
var (
errRegressionWriteTimeout = errors.New("injected recoverable connection write timeout")
regressionKey = []byte("isolated-public-test-key")
)
// regressionTransport fails exactly one Write, then accepts later writes. This
// models an individual mux stream write timing out while its session survives.
type regressionTransport struct {
mu sync.Mutex
buffer bytes.Buffer
calls int
failures int
failNext bool
closed chan struct{}
once sync.Once
}
func (w *regressionTransport) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.calls++
if w.failNext {
w.failNext = false
w.failures++
return 0, errRegressionWriteTimeout
}
select {
case <-w.closed:
return 0, io.ErrClosedPipe
default:
}
return w.buffer.Write(p)
}
func (w *regressionTransport) arm() {
w.mu.Lock()
w.failNext = true
w.mu.Unlock()
}
func (w *regressionTransport) snapshot() (calls, failures, size int) {
w.mu.Lock()
defer w.mu.Unlock()
return w.calls, w.failures, w.buffer.Len()
}
func (w *regressionTransport) close() {
w.once.Do(func() { close(w.closed) })
}
// regressionControl uses the production FRP message encoder and production
// golib crypto.Writer. Only the final transport and incoming messages are fake.
type regressionControl struct {
w *libcrypto.Writer
transport *regressionTransport
writes chan error
incoming chan Message
beforeRead func() error
beforeWrite func()
closeCalls atomic.Int32
}
func newRegressionControl(t *testing.T) *regressionControl {
t.Helper()
transport := &regressionTransport{closed: make(chan struct{})}
w, err := libcrypto.NewWriter(transport, regressionKey)
if err != nil {
t.Fatal(err)
}
return &regressionControl{
w: w, transport: transport, writes: make(chan error, 100), incoming: make(chan Message, 1),
}
}
func (c *regressionControl) ReadMsg() (Message, error) {
if c.beforeRead != nil {
if err := c.beforeRead(); err != nil {
return nil, err
}
}
select {
case m := <-c.incoming:
return m, nil
case <-c.transport.closed:
return nil, io.EOF
}
}
func (c *regressionControl) ReadMsgInto(Message) error {
_, err := c.ReadMsg()
return err
}
func (c *regressionControl) WriteMsg(m Message) error {
if c.beforeWrite != nil {
c.beforeWrite()
}
err := WriteMsg(c.w, m)
c.writes <- err
return err
}
func (c *regressionControl) Close() error {
c.closeCalls.Add(1)
c.transport.close()
return nil
}
func regressionReceiveWrite(t *testing.T, c *regressionControl) error {
t.Helper()
select {
case err := <-c.writes:
return err
case <-time.After(time.Second):
t.Fatal("dispatcher did not attempt WriteMsg")
return nil
}
}
func regressionAwait(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
func regressionWarm(t *testing.T, c *regressionControl) {
t.Helper()
if err := c.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
<-c.writes
c.transport.mu.Lock()
raw := append([]byte(nil), c.transport.buffer.Bytes()...)
c.transport.mu.Unlock()
m, err := ReadMsg(libcrypto.NewReader(bytes.NewReader(raw), regressionKey))
if err != nil {
t.Fatalf("encrypted control roundtrip: %v", err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T", m)
}
}
func TestControlCryptoWriterKeepsErrorAfterTransportRecovery(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
c.transport.arm()
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("want timeout, got %v", err)
}
<-c.writes
beforeCalls, failures, beforeBytes := c.transport.snapshot()
for range 5 {
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("sticky error missing: %v", err)
}
<-c.writes
}
afterCalls, _, afterBytes := c.transport.snapshot()
if beforeCalls != afterCalls || beforeBytes != afterBytes || failures != 1 {
t.Fatalf("unexpected retry: calls %d -> %d, failures %d, bytes %d -> %d",
beforeCalls, afterCalls, failures, beforeBytes, afterBytes)
}
if n, err := c.transport.Write([]byte("healthy underlying transport")); err != nil || n == 0 {
t.Fatalf("underlying transport did not recover: %v", err)
}
t.Log("one recoverable write error permanently invalidates this crypto writer; recovered underlying transport is never retried")
}
func TestDispatcherWriteErrorClosesControlAndFreshSessionWorks(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
d := NewDispatcher(c)
d.Run()
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatal(err)
}
regressionAwait(t, c.transport.closed, "failed control connection close")
regressionAwait(t, d.Done(), "failed dispatcher completion")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after completed shutdown: %v", err)
}
if c.closeCalls.Load() != 1 {
t.Fatalf("connection closed %d times", c.closeCalls.Load())
}
fresh := newRegressionControl(t)
defer fresh.transport.close()
d2 := NewDispatcher(fresh)
d2.Run()
for range 5 {
if err := d2.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, fresh); err != nil {
t.Fatalf("fresh session write failed: %v", err)
}
}
fresh.transport.mu.Lock()
raw := append([]byte(nil), fresh.transport.buffer.Bytes()...)
fresh.transport.mu.Unlock()
r := libcrypto.NewReader(bytes.NewReader(raw), regressionKey)
for i := range 5 {
m, err := ReadMsg(r)
if err != nil {
t.Fatalf("new stream decode %d: %v", i, err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T", m)
}
}
fresh.transport.close()
regressionAwait(t, d2.Done(), "new-session cleanup")
t.Log("write error closes failed control; new session sends and decrypts five valid ReqWorkConn messages")
}
func TestDispatcherWriteErrorWaitsForSynchronousHandler(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
d := NewDispatcher(c)
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
d.RegisterHandler(&ReqWorkConn{}, func(Message) {
close(entered)
<-release
})
d.Run()
c.incoming <- &ReqWorkConn{}
regressionAwait(t, entered, "synchronous handler entry")
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatal(err)
}
regressionAwait(t, c.transport.closed, "write failure closes control during handler")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after stop while handler is active: %v", err)
}
// Let the writer finish stopping while the handler remains blocked.
synctest.Wait()
select {
case <-d.Done():
t.Fatal("Done closed before synchronous handler returned; owner cleanup could race proxy registration")
default:
}
releaseOnce.Do(func() { close(release) })
regressionAwait(t, d.Done(), "completion after handler returned")
})
}
func TestDispatcherWriteErrorUnblocksSenderWithFullQueue(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
c.beforeWrite = func() { close(entered); <-release }
c.transport.arm()
d := NewDispatcher(c)
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
d.Run()
regressionAwait(t, entered, "blocked transport write")
for i := 0; i < cap(d.sendCh); i++ {
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
}
sent := make(chan error, 1)
go func() { sent <- d.Send(&ReqWorkConn{}) }()
// Wait until the sender has reached its full-queue channel wait.
synctest.Wait()
select {
case err := <-sent:
t.Fatalf("Send did not block on full queue: %v", err)
default:
}
releaseOnce.Do(func() { close(release) })
select {
case err := <-sent:
if !errors.Is(err, io.EOF) {
t.Fatalf("blocked Send after write failure: %v", err)
}
case <-time.After(time.Second):
t.Fatal("write failure did not unblock full-queue sender")
}
regressionAwait(t, d.Done(), "full-queue dispatcher cleanup")
})
}
func TestDispatcherConcurrentReadWriteFailureClosesOnce(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
readEntered, writeEntered := make(chan struct{}), make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
c.beforeRead = func() error {
close(readEntered)
<-release
return io.EOF
}
c.beforeWrite = func() {
close(writeEntered)
<-release
}
d := NewDispatcher(c)
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
readExited, writeExited := make(chan struct{}), make(chan struct{})
go func() {
defer close(readExited)
d.readLoop()
}()
go func() {
defer close(writeExited)
d.sendLoop()
}()
regressionAwait(t, readEntered, "read path before injected failure")
regressionAwait(t, writeEntered, "write path before injected failure")
releaseOnce.Do(func() { close(release) })
regressionAwait(t, readExited, "failed read loop exit")
regressionAwait(t, writeExited, "failed write loop exit")
regressionAwait(t, d.Done(), "concurrent read/write shutdown")
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("concurrent WriteMsg failure: %v", err)
}
if _, failures, _ := c.transport.snapshot(); failures != 1 {
t.Fatalf("injected transport failed %d times", failures)
}
if c.closeCalls.Load() != 1 {
t.Fatalf("connection closed %d times", c.closeCalls.Load())
}
}
+225
View File
@@ -0,0 +1,225 @@
// 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 msg
import (
"errors"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
fmux "github.com/fatedier/yamux"
"github.com/fatedier/frp/pkg/proto/wire"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
// Delay one real TCP write without injecting an error. The stream's write
// failure must come from yamux's ConnectionWriteTimeout.
type delayedTCPConn struct {
net.Conn
armed atomic.Bool
entered chan struct{}
release chan struct{}
}
func (c *delayedTCPConn) Write(p []byte) (int, error) {
if c.armed.Swap(false) {
close(c.entered)
<-c.release
}
return c.Conn.Write(p)
}
type observedYamuxConn struct {
net.Conn
writes chan error
readEntered chan struct{}
readOnce sync.Once
closed chan struct{}
closeCalls atomic.Int32
}
func (c *observedYamuxConn) Read(p []byte) (int, error) {
c.readOnce.Do(func() { close(c.readEntered) })
return c.Conn.Read(p)
}
func (c *observedYamuxConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
c.writes <- err
return n, err
}
func (c *observedYamuxConn) Close() error {
c.closeCalls.Add(1)
err := c.Conn.Close()
close(c.closed)
return err
}
func newLoopbackYamuxPair(t *testing.T) (*fmux.Session, *fmux.Session, *delayedTCPConn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
clientTCP, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { clientTCP.Close() })
serverTCP, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { serverTCP.Close() })
gate := &delayedTCPConn{Conn: clientTCP, entered: make(chan struct{}), release: make(chan struct{})}
config := fmux.DefaultConfig()
config.ConnectionWriteTimeout = 80 * time.Millisecond
config.LogOutput = io.Discard
client, err := fmux.Client(gate, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { client.Close() })
server, err := fmux.Server(serverTCP, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { server.Close() })
return client, server, gate
}
func loopbackCryptoConn(t *testing.T, conn net.Conn, protocol string, role netpkg.AEADCryptoRole) *Conn {
t.Helper()
var rw io.ReadWriter
var err error
if protocol == wire.ProtocolV2 {
rw, err = netpkg.NewAEADCryptoReadWriter(
conn, regressionKey, role, wire.AEADAlgorithmAES256GCM, []byte("isolated-test-transcript"))
} else {
rw, err = netpkg.NewCryptoReadWriter(conn, regressionKey)
}
if err != nil {
t.Fatal(err)
}
return NewConn(conn, NewReadWriter(rw, protocol))
}
func loopbackControlPair(t *testing.T, client, server *fmux.Session, protocol string) (*Conn, *Conn, *observedYamuxConn) {
t.Helper()
local, err := client.OpenStream()
if err != nil {
t.Fatal(err)
}
remote, err := server.AcceptStream()
if err != nil {
t.Fatal(err)
}
observed := &observedYamuxConn{
Conn: local, writes: make(chan error, 16), readEntered: make(chan struct{}), closed: make(chan struct{}),
}
return loopbackCryptoConn(t, observed, protocol, netpkg.AEADCryptoRoleClient),
loopbackCryptoConn(t, remote, protocol, netpkg.AEADCryptoRoleServer), observed
}
func loopbackReadReq(t *testing.T, conn *Conn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
defer conn.SetReadDeadline(time.Time{})
m, err := conn.ReadMsg()
if err != nil {
t.Fatal(err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T, want ReqWorkConn", m)
}
}
func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
for _, protocol := range []string{wire.ProtocolV1, wire.ProtocolV2} {
t.Run(protocol, func(t *testing.T) {
client, server, gate := newLoopbackYamuxPair(t)
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(gate.release) })
control, peer, observed := loopbackControlPair(t, client, server, protocol)
if err := control.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
loopbackReadReq(t, peer)
// Warm-up has completed, including all crypto header writes.
for len(observed.writes) > 0 {
<-observed.writes
}
d := NewDispatcher(control)
readExited := make(chan struct{})
go func() {
defer close(readExited)
d.readLoop()
}()
regressionAwait(t, observed.readEntered, "control reader entry")
gate.armed.Store(true)
// Queue before starting the writer so this Send cannot race its failure.
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
go d.sendLoop()
regressionAwait(t, gate.entered, "TCP write delay barrier")
select {
case err := <-observed.writes:
if !errors.Is(err, fmux.ErrConnectionWriteTimeout) {
t.Fatalf("stream write error: %v, want yamux connection write timeout", err)
}
t.Logf("production yamux write failed: %v", err)
case <-time.After(time.Second):
t.Fatal("yamux write did not time out")
}
regressionAwait(t, d.stopCh, "write failure stops sends")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after stop: %v, want EOF", err)
}
releaseOnce.Do(func() { close(gate.release) })
regressionAwait(t, observed.closed, "control Close returns")
// peer never closes its write half: local yamux Close alone cannot
// interrupt Read. Neither a peer FIN nor session cleanup may help.
regressionAwait(t, d.Done(), "Done without peer FIN")
regressionAwait(t, readExited, "control readLoop exit without peer FIN")
if observed.closeCalls.Load() != 1 {
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
}
if client.IsClosed() || server.IsClosed() {
t.Fatal("yamux session unexpectedly closed")
}
fresh, freshPeer, freshObserved := loopbackControlPair(t, client, server, protocol)
for range 5 {
if err := fresh.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
loopbackReadReq(t, freshPeer)
for len(freshObserved.writes) > 0 {
<-freshObserved.writes
}
}
t.Log("Done/readLoop exited without peer FIN; Close once; same yamux session decrypted five fresh messages")
})
}
}
-1
View File
@@ -124,7 +124,6 @@ type NewProxy struct {
Headers map[string]string `json:"headers,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
HTTPRedirect bool `json:"http_redirect,omitempty"`
// stcp, sudp, xtcp
Sk string `json:"sk,omitempty"`
+5 -3
View File
@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
// Make hole-punching decisions based on the NAT information of the client and visitor.
vResp, cResp, err := c.analysis(session)
if err != nil {
log.Debugf("sid [%s] analysis error: %v", sid, err)
log.Debugf("sid [%s] analysis error: %v", err)
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
}
@@ -385,6 +385,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if !isLast {
return nil
}
ports := make([]msg.PortsRange, 0, 1)
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil
@@ -393,8 +394,9 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if err != nil {
return nil
}
return []msg.PortsRange{{
ports = append(ports, msg.PortsRange{
From: max(port-difference-5, port-maxNumber, 1),
To: min(port+difference+5, port+maxNumber, 65535),
}}
})
return ports
}
-212
View File
@@ -1,212 +0,0 @@
// Copyright 2026 The LoliaTeam 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.
//go:build !frps
package client
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/log"
)
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
if auto == nil || !auto.Enable {
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
}
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
}
hostSet := make(map[string]struct{})
hosts := make([]string, 0, len(auto.HostAllowList))
addHost := func(host string) {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return
}
if strings.Contains(host, "*") {
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
return
}
if _, ok := hostSet[host]; ok {
return
}
hostSet[host] = struct{}{}
hosts = append(hosts, host)
}
for _, host := range auto.HostAllowList {
addHost(host)
}
if len(hosts) == 0 {
for _, host := range fallbackHosts {
addHost(host)
}
}
if len(hosts) == 0 {
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
}
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: strings.TrimSpace(auto.Email),
HostPolicy: autocert.HostWhitelist(hosts...),
}
caDirURL := strings.TrimSpace(auto.CADirURL)
if caDirURL != "" {
manager.Client = &acme.Client{DirectoryURL: caDirURL}
} else {
caDirURL = autocert.DefaultACMEDirectory
}
managedHosts := make(map[string]struct{}, len(hosts))
for _, host := range hosts {
managedHosts[host] = struct{}{}
}
var warmupInProgress sync.Map
var warmupMissLogged sync.Map
manager.Cache = &autoTLSCache{
inner: autocert.DirCache(auto.CacheDir),
managedHosts: managedHosts,
pluginName: pluginName,
caDirURL: caDirURL,
warmupInProgress: &warmupInProgress,
warmupMissLogged: &warmupMissLogged,
}
cfg := manager.TLSConfig()
log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir)
var readySeen sync.Map
handleCertReady := func(host string, cert *tls.Certificate) {
var (
notAfter time.Time
hasExpiry bool
)
if t, ok := getCertificateNotAfter(cert); ok {
notAfter = t
hasExpiry = true
}
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
if hasExpiry {
if !readyLogged {
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
}
} else if !readyLogged {
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
}
}
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
if host == "" {
host = "<空SNI>"
}
cert, err := manager.GetCertificate(hello)
if err != nil {
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
return nil, err
}
handleCertReady(host, cert)
return cert, nil
}
// Warm up certificates in background after startup.
for _, host := range hosts {
h := host
go func() {
// Leave time for listener setup and route registration.
time.Sleep(1 * time.Second)
warmupMissLogged.Delete(h)
warmupInProgress.Store(h, struct{}{})
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
warmupInProgress.Delete(h)
if err != nil {
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
return
}
handleCertReady(h, cert)
}()
}
return cfg, nil
}
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
if cert == nil {
return time.Time{}, false
}
if cert.Leaf != nil {
return cert.Leaf.NotAfter, true
}
if len(cert.Certificate) == 0 {
return time.Time{}, false
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return time.Time{}, false
}
return leaf.NotAfter, true
}
type autoTLSCache struct {
inner autocert.Cache
managedHosts map[string]struct{}
pluginName string
caDirURL string
warmupInProgress *sync.Map
warmupMissLogged *sync.Map
}
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
data, err := c.inner.Get(ctx, key)
if err != autocert.ErrCacheMiss {
return data, err
}
host := strings.TrimSuffix(key, "+rsa")
if _, ok := c.managedHosts[host]; !ok {
return data, err
}
if _, warming := c.warmupInProgress.Load(host); !warming {
return data, err
}
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-01,caDirURL=%s", c.pluginName, host, c.caDirURL)
}
return data, err
}
func (c *autoTLSCache) Put(ctx context.Context, key string, data []byte) error {
return c.inner.Put(ctx, key, data)
}
func (c *autoTLSCache) Delete(ctx context.Context, key string) error {
return c.inner.Delete(ctx, key)
}
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 The LoliaTeam 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.
//go:build !frps
package client
import (
"context"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
}
type HTTP2HTTPSRedirectPlugin struct {
opts *v1.HTTP2HTTPSRedirectPluginOptions
l *Listener
s *http.Server
}
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPSRedirectPlugin{
opts: opts,
l: listener,
}
p.s = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Not an open redirect: the target scheme is fixed to https and the
// host is the one the client already connected to.
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound) //nolint:gosec // G710
}),
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
return p, nil
}
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
host := strings.TrimSpace(req.Host)
if host == "" {
host = strings.TrimSpace(req.URL.Host)
}
targetHost := host
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
targetHost = parsedHost
if httpsPort == 0 && parsedPort == "443" {
httpsPort = 443
}
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
}
if httpsPort != 0 && httpsPort != 443 {
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
}
return (&url.URL{
Scheme: "https",
Host: targetHost,
Path: req.URL.Path,
RawPath: req.URL.RawPath,
RawQuery: req.URL.RawQuery,
}).String()
}
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
if connInfo.SrcAddr != nil {
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
}
_ = p.l.PutConn(wrapConn)
}
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
return v1.PluginHTTP2HTTPSRedirect
}
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
return p.s.Close()
}
+20 -20
View File
@@ -18,7 +18,6 @@ package client
import (
"context"
"crypto/tls"
stdlog "log"
"net/http"
"net/http/httputil"
@@ -54,6 +53,7 @@ func rewriteHTTPPluginRequest(
type httpBridgePlugin struct {
l *Listener
s *http.Server
httpsServer *httpsserver.Server
useSourceRemoteAddr bool
}
@@ -81,36 +81,21 @@ func newHTTPSBridgePluginServer(
enableHTTP2 *bool,
useSourceRemoteAddr bool,
) (*httpBridgePlugin, error) {
listener := NewProxyListener()
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
if err != nil {
return nil, err
}
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
}
// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
func newHTTPSBridgePluginServerWithTLSConfig(
handler http.Handler,
tlsConfig *tls.Config,
enableHTTP2 *bool,
useSourceRemoteAddr bool,
) *httpBridgePlugin {
server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
}
func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
listener := NewProxyListener()
p := &httpBridgePlugin{
l: listener,
s: server,
s: server.Server,
httpsServer: server,
useSourceRemoteAddr: useSourceRemoteAddr,
}
go func() {
_ = p.s.ServeTLS(listener, "", "")
}()
return p
return p, nil
}
func newHTTPBridgeReverseProxy(
@@ -141,3 +126,18 @@ func (p *httpBridgePlugin) Close() error {
_ = p.l.Close()
return err
}
// Reload refreshes only resources owned by this plugin; HTTP bridges are no-op.
func (p *httpBridgePlugin) Reload() error {
if p.httpsServer == nil {
return nil
}
return p.httpsServer.Reload()
}
func (p *httpBridgePlugin) ReloadError() error {
if p.httpsServer == nil {
return nil
}
return p.httpsServer.ReloadError()
}
+1 -11
View File
@@ -17,7 +17,6 @@
package client
import (
"fmt"
"net/http/httputil"
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -33,7 +32,7 @@ type HTTPS2HTTPPlugin struct {
*httpBridgePlugin
}
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPPluginOptions)
p := &HTTPS2HTTPPlugin{
@@ -50,15 +49,6 @@ func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions
nil,
)
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, fmt.Errorf("build autoTLS config error: %v", err)
}
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
return p, nil
}
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
if err != nil {
return nil, err
+1 -11
View File
@@ -18,7 +18,6 @@ package client
import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httputil"
@@ -35,7 +34,7 @@ type HTTPS2HTTPSPlugin struct {
*httpBridgePlugin
}
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
p := &HTTPS2HTTPSPlugin{
@@ -56,15 +55,6 @@ func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOption
tr,
)
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, fmt.Errorf("build autoTLS config error: %v", err)
}
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
return p, nil
}
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
if err != nil {
return nil, err
@@ -17,9 +17,13 @@
package httpsserver
import (
"bytes"
"crypto/tls"
"fmt"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"github.com/samber/lo"
@@ -28,18 +32,38 @@ import (
httppkg "github.com/fatedier/frp/pkg/util/http"
)
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
// snapshot is immutable after publication. Errors retain the last usable pair.
type snapshot struct {
cert *tls.Certificate
certPEM, keyPEM []byte
err error
}
type Server struct {
*http.Server
crtPath, keyPath string
reloadMu sync.Mutex
state atomic.Pointer[snapshot]
}
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*Server, error) {
server := &Server{crtPath: crtPath, keyPath: keyPath}
var tlsConfig *tls.Config
var err error
if crtPath == "" || keyPath == "" {
tlsConfig, err = transport.NewServerTLSConfig(crtPath, keyPath, "")
} else {
err = server.Reload()
tlsConfig = &tls.Config{GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return server.state.Load().cert, nil
}}
}
if err != nil {
return nil, fmt.Errorf("gen TLS config error: %v", err)
}
return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
}
// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
// It is used by features such as autoTLS that supply their own certificate provider.
func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
server := &http.Server{
server.Server = &http.Server{
Handler: withMisdirectedRequestCheck(handler),
ReadHeaderTimeout: 60 * time.Second,
TLSConfig: tlsConfig,
@@ -47,7 +71,7 @@ func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *
if !lo.FromPtr(enableHTTP2) {
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
}
return server
return server, nil
}
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
@@ -63,3 +87,51 @@ func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
handler.ServeHTTP(w, r)
})
}
func (s *Server) Reload() (err error) {
if s.crtPath == "" || s.keyPath == "" {
return nil
}
s.reloadMu.Lock()
defer s.reloadMu.Unlock()
old := s.state.Load()
// Publish the error before releasing reloadMu; status never waits for I/O.
defer func() {
if err != nil {
next := snapshot{err: err}
if old != nil {
next.cert, next.certPEM, next.keyPEM = old.cert, old.certPEM, old.keyPEM
}
s.state.Store(&next)
}
}()
certPEM, err := os.ReadFile(s.crtPath)
if err != nil {
return fmt.Errorf("read certificate: %w", err)
}
keyPEM, err := os.ReadFile(s.keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
if old != nil && old.cert != nil && bytes.Equal(certPEM, old.certPEM) && bytes.Equal(keyPEM, old.keyPEM) {
if old.err != nil {
next := *old
next.err = nil
s.state.Store(&next)
}
return nil
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("parse certificate pair: %w", err)
}
s.state.Store(&snapshot{cert: &cert, certPEM: certPEM, keyPEM: keyPEM})
return nil
}
func (s *Server) ReloadError() error {
if v := s.state.Load(); v != nil {
return v.err
}
return nil
}
@@ -0,0 +1,153 @@
// 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.
//go:build !frps
package httpsserver
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func certificatePEM(t *testing.T, serial int64) ([]byte, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(serial),
DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
}
func handshake(t *testing.T, cfg *tls.Config) (*tls.Conn, *tls.Conn) {
t.Helper()
clientRaw, serverRaw := net.Pipe()
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
require.NoError(t, serverRaw.SetDeadline(time.Now().Add(5*time.Second)))
client := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true})
server := tls.Server(serverRaw, cfg)
done := make(chan error, 1)
go func() { done <- server.Handshake() }()
require.NoError(t, client.Handshake())
require.NoError(t, <-done)
return client, server
}
func TestReloadCertificate(t *testing.T) {
dir := t.TempDir()
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
certPEM, keyPEM := certificatePEM(t, 1)
require.NoError(t, os.WriteFile(crtPath, certPEM, 0o600))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
s, err := New(nil, crtPath, keyPath, nil)
require.NoError(t, err)
originalState := s.state.Load()
original := originalState.cert
oldClient, oldServer := handshake(t, s.TLSConfig)
require.Equal(t, int64(1), oldClient.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, s.Reload())
require.Same(t, originalState, s.state.Load(), "unchanged bytes must not parse or publish a new pair")
require.NoError(t, s.ReloadError())
for _, tc := range []struct {
name, path string
data []byte
}{
{"missing certificate", crtPath, nil},
{"missing key", keyPath, nil},
{"invalid certificate", crtPath, []byte("invalid")},
{"invalid key", keyPath, []byte("invalid")},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.data == nil {
require.NoError(t, os.Remove(tc.path))
} else {
require.NoError(t, os.WriteFile(tc.path, tc.data, 0o600))
}
require.Error(t, s.Reload())
require.Error(t, s.ReloadError())
require.Same(t, original, s.state.Load().cert)
client, _ := handshake(t, s.TLSConfig)
require.Equal(t, int64(1), client.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, os.WriteFile(crtPath, certPEM, 0o600))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
require.NoError(t, s.Reload())
require.NoError(t, s.ReloadError())
require.Same(t, original, s.state.Load().cert)
})
}
// Even a key-only byte change must be noticed, regardless of parsed identity.
require.NoError(t, os.WriteFile(keyPath, append(keyPEM, '\n'), 0o600))
require.NoError(t, s.Reload())
require.NotSame(t, original, s.state.Load().cert)
nextCert, nextKey := certificatePEM(t, 2)
require.NoError(t, os.WriteFile(crtPath, nextCert, 0o600))
beforeMismatch := s.state.Load().cert
require.Error(t, s.Reload(), "mismatched pair must retain the active certificate")
require.Same(t, beforeMismatch, s.state.Load().cert)
require.NoError(t, os.WriteFile(keyPath, nextKey, 0o600))
beforeReload, _ := handshake(t, s.TLSConfig)
require.Equal(t, int64(1), beforeReload.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, s.Reload())
newClient, _ := handshake(t, s.TLSConfig)
require.Equal(t, int64(2), newClient.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, oldClient.SetDeadline(time.Now().Add(5*time.Second)))
require.NoError(t, oldServer.SetDeadline(time.Now().Add(5*time.Second)))
done := make(chan error, 1)
go func() { _, err := oldServer.Write([]byte("still connected")); done <- err }()
data := make([]byte, len("still connected"))
_, err = io.ReadFull(oldClient, data)
require.NoError(t, err)
require.NoError(t, <-done)
require.Equal(t, "still connected", string(data))
}
func TestReloadCertificateKeepsSelfSigned(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing")
for _, paths := range [][2]string{{"", ""}, {missing, ""}, {"", missing}} {
s, err := New(nil, paths[0], paths[1], nil)
require.NoError(t, err)
original := &s.TLSConfig.Certificates[0]
require.Nil(t, s.TLSConfig.GetCertificate)
require.NoError(t, s.Reload())
require.Same(t, original, &s.TLSConfig.Certificates[0])
}
}
-1
View File
@@ -30,7 +30,6 @@ import (
type PluginContext struct {
Name string
HostAllowList []string
VnetController *vnet.Controller
}
+2 -11
View File
@@ -39,26 +39,17 @@ type TLS2RawPlugin struct {
tlsConfig *tls.Config
}
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.TLS2RawPluginOptions)
p := &TLS2RawPlugin{
opts: opts,
}
var tlsConfig *tls.Config
var err error
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
}
} else {
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
}
}
p.tlsConfig = tlsConfig
return p, nil
}
+22 -50
View File
@@ -17,13 +17,13 @@ package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
charmlog "github.com/charmbracelet/log"
goliblog "github.com/fatedier/golib/log"
"github.com/fatedier/frp/pkg/msg"
frplog "github.com/fatedier/frp/pkg/util/log"
@@ -39,8 +39,8 @@ type testPlugin struct {
var logCaptureMu sync.Mutex
type logCapture struct {
mu sync.Mutex
buf bytes.Buffer
bytes.Buffer
levels []goliblog.Level
}
func (p testPlugin) Name() string {
@@ -55,36 +55,9 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
return p.handler(ctx, op, content)
}
func (w *logCapture) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Write(p)
}
func (w *logCapture) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
// levels parses the captured JSON log output and returns the level of each entry.
func (w *logCapture) levels() []charmlog.Level {
var levels []charmlog.Level
for line := range strings.SplitSeq(strings.TrimSpace(w.String()), "\n") {
if line == "" {
continue
}
var entry struct {
Level string `json:"level"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
levels = append(levels, lvl)
}
}
return levels
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
w.levels = append(w.levels, level)
return w.Write(p)
}
func captureLogOutput(t *testing.T) *logCapture {
@@ -93,10 +66,11 @@ func captureLogOutput(t *testing.T) *logCapture {
logCaptureMu.Lock()
logOutput := &logCapture{}
oldLogger := frplog.Logger
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
Level: charmlog.DebugLevel,
Formatter: charmlog.JSONFormatter,
})
frplog.Logger = goliblog.New(
goliblog.WithOutput(logOutput),
goliblog.WithLevel(goliblog.TraceLevel),
goliblog.WithCaller(false),
)
t.Cleanup(func() {
frplog.Logger = oldLogger
logCaptureMu.Unlock()
@@ -289,10 +263,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
tests := []struct {
name string
op string
level charmlog.Level
level goliblog.Level
}{
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
}
for _, tt := range tests {
@@ -314,9 +288,8 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
t.Fatalf("unexpected error: %v", err)
}
levels := logOutput.levels()
if len(levels) != 1 || levels[0] != tt.level {
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
}
})
}
@@ -352,13 +325,12 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
t.Fatalf("missing aggregated errors: %v", err)
}
levels := logOutput.levels()
if len(levels) != 2 {
t.Fatalf("expected two warning logs, got %v", levels)
if len(logOutput.levels) != 2 {
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
}
for _, level := range levels {
if level != charmlog.WarnLevel {
t.Fatalf("expected warning log level, got %v", levels)
for _, level := range logOutput.levels {
if level != goliblog.WarnLevel {
t.Fatalf("expected warning log level, got %v", logOutput.levels)
}
}
}
+85 -28
View File
@@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
@@ -33,9 +34,15 @@ func init() {
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
}
type clientRouteController interface {
RegisterClientRoute(context.Context, string, []net.IPNet, io.ReadWriteCloser)
UnregisterClientRoute(string, io.Writer) bool
}
type VirtualNetPlugin struct {
pluginCtx PluginContext
routeController clientRouteController
routes []net.IPNet
mu sync.Mutex
@@ -48,6 +55,11 @@ type VirtualNetPlugin struct {
cancel context.CancelFunc
}
const (
virtualNetReconnectBaseDelay = 60 * time.Second
virtualNetReconnectMaxDelay = 300 * time.Second
)
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
opts := options.(*v1.VirtualNetVisitorPluginOptions)
@@ -55,6 +67,9 @@ func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOption
pluginCtx: pluginCtx,
routes: make([]net.IPNet, 0),
}
if pluginCtx.VnetController != nil {
p.routeController = pluginCtx.VnetController
}
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
@@ -85,7 +100,7 @@ func (p *VirtualNetPlugin) Name() string {
func (p *VirtualNetPlugin) Start() {
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
if p.pluginCtx.VnetController == nil {
if p.routeController == nil {
return
}
@@ -111,16 +126,17 @@ func (p *VirtualNetPlugin) run() {
select {
case <-p.ctx.Done():
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
p.cleanupControllerConn(xl)
p.cleanupCurrentControllerConn(xl)
return
default:
}
controllerConn, pluginConn := net.Pipe()
p.mu.Lock()
p.controllerConn = controllerConn
p.mu.Unlock()
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
if !p.registerControllerConn(controllerConn, pluginConn) {
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before route registration).", p.pluginCtx.Name)
return
}
// Wrap with CloseNotifyConn which supports both close notification and error recording
var closeErr error
@@ -129,8 +145,6 @@ func (p *VirtualNetPlugin) run() {
close(currentCloseSignal) // Signal the run loop on close.
})
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
// Pass the CloseNotifyConn to the visitor for handling.
@@ -141,7 +155,7 @@ func (p *VirtualNetPlugin) run() {
select {
case <-p.ctx.Done():
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
p.cleanupControllerConn(xl)
p.cleanupControllerConn(xl, controllerConn)
return
case <-currentCloseSignal:
// Determine reconnect delay based on error with exponential backoff
@@ -152,8 +166,7 @@ func (p *VirtualNetPlugin) run() {
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
baseDelay := 60 * time.Second
reconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)
reconnectDelay = virtualNetReconnectDelay(p.consecutiveErrors)
} else {
// Reset consecutive errors on successful connection
if p.consecutiveErrors > 0 {
@@ -167,7 +180,7 @@ func (p *VirtualNetPlugin) run() {
}
// The visitor closed the plugin side. Close the controller side.
p.cleanupControllerConn(xl)
p.cleanupControllerConn(xl, controllerConn)
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
select {
@@ -182,16 +195,66 @@ func (p *VirtualNetPlugin) run() {
}
}
// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
// registerControllerConn publishes and registers controllerConn atomically with
// respect to Close. A canceled plugin cannot register a new route.
func (p *VirtualNetPlugin) registerControllerConn(controllerConn, pluginConn net.Conn) bool {
p.mu.Lock()
if p.ctx.Err() != nil || p.routeController == nil {
p.mu.Unlock()
_ = controllerConn.Close()
_ = pluginConn.Close()
return false
}
p.controllerConn = controllerConn
p.routeController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
p.mu.Unlock()
return true
}
// virtualNetReconnectDelay returns a bounded reconnect delay without allowing
// the exponential shift to overflow for large consecutive error counts.
func virtualNetReconnectDelay(consecutiveErrors int) time.Duration {
if consecutiveErrors <= 1 {
return virtualNetReconnectBaseDelay
}
if consecutiveErrors >= 4 {
return virtualNetReconnectMaxDelay
}
return virtualNetReconnectBaseDelay * time.Duration(1<<uint(consecutiveErrors-1))
}
// cleanupControllerConn unregisters and closes one connection round without
// affecting a replacement route owned by another connection.
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger, controllerConn net.Conn) {
p.mu.Lock()
defer p.mu.Unlock()
if p.controllerConn != nil {
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
p.controllerConn.Close()
p.controllerConn = nil
}
p.cleanupControllerConnLocked(xl, controllerConn)
}
func (p *VirtualNetPlugin) cleanupCurrentControllerConn(xl *xlog.Logger) {
p.mu.Lock()
defer p.mu.Unlock()
p.cleanupControllerConnLocked(xl, p.controllerConn)
}
// cleanupControllerConnLocked must be called with p.mu held.
func (p *VirtualNetPlugin) cleanupControllerConnLocked(xl *xlog.Logger, controllerConn net.Conn) {
if controllerConn == nil {
p.closeSignal = nil
return
}
if p.routeController != nil &&
p.routeController.UnregisterClientRoute(p.pluginCtx.Name, controllerConn) {
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
}
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
_ = controllerConn.Close()
if p.controllerConn == controllerConn {
p.controllerConn = nil
p.closeSignal = nil
}
}
// Close initiates the plugin shutdown.
@@ -202,15 +265,9 @@ func (p *VirtualNetPlugin) Close() error {
// Signal the run loop goroutine to stop.
p.cancel()
// Unregister the route from the controller.
if p.pluginCtx.VnetController != nil {
p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
}
// Explicitly close the controller side of the pipe.
// This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end.
p.cleanupControllerConn(xl)
// Unregister and close the current connection while holding the same lock
// used to check cancellation and register a route in run.
p.cleanupCurrentControllerConn(xl)
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
return nil
+245
View File
@@ -0,0 +1,245 @@
// 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.
//go:build !frps
package visitor
import (
"context"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/util/xlog"
)
const testVirtualNetVisitorName = "vnet-visitor"
type fakeClientRouteController struct {
mu sync.Mutex
routes map[string]io.Writer
beforeRegister func()
registerCalls int
unregisterCalls int
}
func newFakeClientRouteController() *fakeClientRouteController {
return &fakeClientRouteController{
routes: make(map[string]io.Writer),
}
}
func (c *fakeClientRouteController) RegisterClientRoute(
_ context.Context,
name string,
_ []net.IPNet,
conn io.ReadWriteCloser,
) {
if c.beforeRegister != nil {
c.beforeRegister()
}
c.mu.Lock()
defer c.mu.Unlock()
c.registerCalls++
c.routes[name] = conn
}
func (c *fakeClientRouteController) UnregisterClientRoute(name string, conn io.Writer) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.unregisterCalls++
owner, ok := c.routes[name]
if !ok || owner != conn {
return false
}
delete(c.routes, name)
return true
}
func (c *fakeClientRouteController) owner() io.Writer {
c.mu.Lock()
defer c.mu.Unlock()
return c.routes[testVirtualNetVisitorName]
}
func (c *fakeClientRouteController) callCounts() (register, unregister int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.registerCalls, c.unregisterCalls
}
type trackedConn struct {
net.Conn
closed atomic.Bool
}
func (c *trackedConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
func newTrackedPipe(t *testing.T) (*trackedConn, *trackedConn) {
t.Helper()
left, right := net.Pipe()
trackedLeft := &trackedConn{Conn: left}
trackedRight := &trackedConn{Conn: right}
t.Cleanup(func() {
_ = trackedLeft.Close()
_ = trackedRight.Close()
})
return trackedLeft, trackedRight
}
func newTestVirtualNetPlugin(t *testing.T, controller *fakeClientRouteController) *VirtualNetPlugin {
t.Helper()
pluginCtx := context.Background()
ctx, cancel := context.WithCancel(pluginCtx)
p := &VirtualNetPlugin{
pluginCtx: PluginContext{
Name: testVirtualNetVisitorName,
Ctx: pluginCtx,
},
routeController: controller,
routes: []net.IPNet{{
IP: net.ParseIP("10.1.0.1"),
Mask: net.CIDRMask(32, 32),
}},
ctx: ctx,
cancel: cancel,
}
t.Cleanup(func() {
_ = p.Close()
})
return p
}
func waitResult[T any](t *testing.T, ch <-chan T) T {
t.Helper()
select {
case result := <-ch:
return result
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent operation")
var zero T
return zero
}
}
// TestVirtualNetReconnectDelay verifies the documented exponential backoff and
// ensures large error counts remain capped instead of overflowing to zero.
func TestVirtualNetReconnectDelay(t *testing.T) {
tests := []struct {
name string
consecutiveErrors int
want time.Duration
}{
{name: "first error", consecutiveErrors: 1, want: 60 * time.Second},
{name: "second error", consecutiveErrors: 2, want: 120 * time.Second},
{name: "third error", consecutiveErrors: 3, want: 240 * time.Second},
{name: "fourth error", consecutiveErrors: 4, want: 300 * time.Second},
{name: "shift width boundary", consecutiveErrors: 64, want: 300 * time.Second},
{name: "observed retry storm", consecutiveErrors: 329769, want: 300 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, virtualNetReconnectDelay(tt.consecutiveErrors))
})
}
}
func TestVirtualNetPluginCloseBeforeRegisterDoesNotReplaceNewRoute(t *testing.T) {
controller := newFakeClientRouteController()
oldPlugin := newTestVirtualNetPlugin(t, controller)
newPlugin := newTestVirtualNetPlugin(t, controller)
oldControllerConn, oldPluginConn := newTrackedPipe(t)
newControllerConn, newPluginConn := newTrackedPipe(t)
allowOldRegister := make(chan struct{})
oldRegisterResult := make(chan bool, 1)
go func() {
<-allowOldRegister
oldRegisterResult <- oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn)
}()
require.NoError(t, oldPlugin.Close())
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
close(allowOldRegister)
require.False(t, waitResult(t, oldRegisterResult))
require.Same(t, newControllerConn, controller.owner())
registerCalls, _ := controller.callCounts()
require.Equal(t, 1, registerCalls)
require.True(t, oldControllerConn.closed.Load())
require.True(t, oldPluginConn.closed.Load())
}
func TestVirtualNetPluginRegisterBeforeCloseIsCleanedUp(t *testing.T) {
controller := newFakeClientRouteController()
p := newTestVirtualNetPlugin(t, controller)
controllerConn, pluginConn := newTrackedPipe(t)
registerEntered := make(chan struct{})
var registerEnteredOnce sync.Once
controller.beforeRegister = func() {
registerEnteredOnce.Do(func() {
close(registerEntered)
})
<-p.ctx.Done()
}
registerResult := make(chan bool, 1)
go func() {
registerResult <- p.registerControllerConn(controllerConn, pluginConn)
}()
waitResult(t, registerEntered)
closeResult := make(chan error, 1)
go func() {
closeResult <- p.Close()
}()
require.NoError(t, waitResult(t, closeResult))
require.True(t, waitResult(t, registerResult))
require.Nil(t, controller.owner())
registerCalls, unregisterCalls := controller.callCounts()
require.Equal(t, 1, registerCalls)
require.Equal(t, 1, unregisterCalls)
require.True(t, controllerConn.closed.Load())
}
func TestVirtualNetPluginOldConnectionCleanupKeepsReplacementRoute(t *testing.T) {
controller := newFakeClientRouteController()
oldPlugin := newTestVirtualNetPlugin(t, controller)
newPlugin := newTestVirtualNetPlugin(t, controller)
oldControllerConn, oldPluginConn := newTrackedPipe(t)
newControllerConn, newPluginConn := newTrackedPipe(t)
require.True(t, oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn))
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
require.Same(t, newControllerConn, controller.owner())
oldPlugin.cleanupControllerConn(xlog.FromContextSafe(oldPlugin.ctx), oldControllerConn)
require.Same(t, newControllerConn, controller.owner())
require.True(t, oldControllerConn.closed.Load())
}
+49 -5
View File
@@ -80,17 +80,23 @@ func newRandomTLSKeyPair() (*tls.Certificate, error) {
// Only support one ca file to add
func newCertPool(caPath string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
if err := appendCertsFromFile(pool, caPath); err != nil {
return nil, err
}
return pool, nil
}
func appendCertsFromFile(pool *x509.CertPool, caPath string) error {
caCrt, err := os.ReadFile(caPath)
if err != nil {
return nil, err
return err
}
if !pool.AppendCertsFromPEM(caCrt) {
return nil, fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
return fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
}
return pool, nil
return nil
}
func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
@@ -126,6 +132,21 @@ func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
}
func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) {
return NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName, false)
}
// NewClientTLSConfigWithSystemCA enables verification against system CAs when
// useSystemCa is true, adding certificates from caPath if it is also set.
// Without either trust source, server certificate verification remains disabled.
func NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName string, useSystemCa bool) (*tls.Config, error) {
return newClientTLSConfig(certPath, keyPath, caPath, serverName, useSystemCa, x509.SystemCertPool)
}
func newClientTLSConfig(
certPath, keyPath, caPath, serverName string,
useSystemCa bool,
systemCertPool func() (*x509.CertPool, error),
) (*tls.Config, error) {
base := &tls.Config{}
if certPath != "" && keyPath != "" {
@@ -139,7 +160,26 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
base.ServerName = serverName
if caPath != "" {
switch {
case caPath != "" && useSystemCa:
// SystemCertPool returns a copy, so appending does not change the system pool.
// Design basis: Go 1.26.7 crypto/x509/verify.go:559-573 first uses the
// Windows/macOS verifier, then falls back to Go verification using only the
// added CAs if platform verification fails. This platform behavior has not been
// tested here; preserve the system pool instead of copying its subjects.
pool, err := systemCertPool()
if err != nil {
return nil, fmt.Errorf("failed to load system CA pool: %w", err)
}
if pool == nil {
return nil, fmt.Errorf("failed to load system CA pool: no pool returned")
}
if err := appendCertsFromFile(pool, caPath); err != nil {
return nil, err
}
base.RootCAs = pool
base.InsecureSkipVerify = false
case caPath != "":
pool, err := newCertPool(caPath)
if err != nil {
return nil, err
@@ -147,7 +187,11 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
base.RootCAs = pool
base.InsecureSkipVerify = false
} else {
case useSystemCa:
// A nil RootCAs makes crypto/tls use the host root store, through the
// platform verifier on macOS and Windows.
base.InsecureSkipVerify = false
default:
base.InsecureSkipVerify = true
}
+250
View File
@@ -0,0 +1,250 @@
// 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 transport
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newSelfSignedCert(t *testing.T, name string) (tls.Certificate, []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
Subject: pkix.Name{CommonName: name},
DNSNames: []string{name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
require.NoError(t, err)
return cert, certPEM
}
func TestNewClientTLSConfigVerification(t *testing.T) {
_, systemPEM := newSelfSignedCert(t, "system.example.com")
_, filePEM := newSelfSignedCert(t, "file.example.com")
caFile := filepath.Join(t.TempDir(), "ca.crt")
require.NoError(t, os.WriteFile(caFile, filePEM, 0o600))
systemPool := x509.NewCertPool()
require.True(t, systemPool.AppendCertsFromPEM(systemPEM))
filePool := x509.NewCertPool()
require.True(t, filePool.AppendCertsFromPEM(filePEM))
combinedPool := systemPool.Clone()
require.True(t, combinedPool.AppendCertsFromPEM(filePEM))
for _, test := range []struct {
name string
caPath string
useSystemCa bool
insecureSkipVerify bool
wantPool *x509.CertPool
}{
{name: "no trust source", insecureSkipVerify: true},
{name: "file CA only", caPath: caFile, wantPool: filePool},
{name: "system CA only", useSystemCa: true},
{name: "system and file CAs", caPath: caFile, useSystemCa: true, wantPool: combinedPool},
} {
t.Run(test.name, func(t *testing.T) {
var loadedPool *x509.CertPool
loads := 0
loadSystemPool := func() (*x509.CertPool, error) {
loads++
loadedPool = systemPool.Clone()
return loadedPool, nil
}
cfg, err := newClientTLSConfig("", "", test.caPath, "example.com", test.useSystemCa, loadSystemPool)
require.NoError(t, err)
require.Equal(t, "example.com", cfg.ServerName)
require.Equal(t, test.insecureSkipVerify, cfg.InsecureSkipVerify)
if test.wantPool == nil {
require.Nil(t, cfg.RootCAs)
} else {
require.NotNil(t, cfg.RootCAs)
require.True(t, cfg.RootCAs.Equal(test.wantPool))
}
if test.caPath != "" && test.useSystemCa {
require.Equal(t, 1, loads)
require.Same(t, loadedPool, cfg.RootCAs, "append to the returned system pool")
} else {
require.Zero(t, loads, "only combined trust needs an explicit system pool")
}
})
}
// The four-argument API keeps the original skip/file-only semantics for
// xtcp visitors and plugins, regardless of the new transport option.
cfg, err := NewClientTLSConfig("", "", "", "example.com")
require.NoError(t, err)
require.True(t, cfg.InsecureSkipVerify)
require.Nil(t, cfg.RootCAs)
cfg, err = NewClientTLSConfig("", "", caFile, "example.com")
require.NoError(t, err)
require.False(t, cfg.InsecureSkipVerify)
require.True(t, cfg.RootCAs.Equal(filePool))
}
func TestNewClientTLSConfigCAErrors(t *testing.T) {
_, caPEM := newSelfSignedCert(t, "localhost")
dir := t.TempDir()
caFile := filepath.Join(dir, "ca.crt")
badFile := filepath.Join(dir, "bad.crt")
require.NoError(t, os.WriteFile(caFile, caPEM, 0o600))
require.NoError(t, os.WriteFile(badFile, []byte("not a PEM certificate"), 0o600))
systemErr := errors.New("system trust store unavailable")
for _, test := range []struct {
name string
caPath string
useSystemCa bool
pool *x509.CertPool
poolErr error
wantErr string
}{
{name: "file missing", caPath: filepath.Join(dir, "missing.crt"), wantErr: "missing.crt"},
{name: "file invalid", caPath: badFile, wantErr: "no valid PEM certificates found"},
{
name: "combined file missing", caPath: filepath.Join(dir, "missing.crt"), useSystemCa: true,
pool: x509.NewCertPool(), wantErr: "missing.crt",
},
{
name: "combined file invalid", caPath: badFile, useSystemCa: true,
pool: x509.NewCertPool(), wantErr: "no valid PEM certificates found",
},
{
name: "system pool error", caPath: caFile, useSystemCa: true,
pool: x509.NewCertPool(), poolErr: systemErr, wantErr: "failed to load system CA pool",
},
{name: "nil system pool", caPath: caFile, useSystemCa: true, wantErr: "no pool returned"},
} {
t.Run(test.name, func(t *testing.T) {
cfg, err := newClientTLSConfig("", "", test.caPath, "localhost", test.useSystemCa,
func() (*x509.CertPool, error) { return test.pool, test.poolErr })
require.ErrorContains(t, err, test.wantErr)
require.Nil(t, cfg, "do not return a partially configured TLS client or fall back")
if test.poolErr != nil {
require.ErrorIs(t, err, test.poolErr)
}
})
}
}
func TestClientTLSConfigHandshake(t *testing.T) {
systemCert, systemPEM := newSelfSignedCert(t, "localhost")
fileCert, filePEM := newSelfSignedCert(t, "localhost")
untrustedCert, _ := newSelfSignedCert(t, "localhost")
caFile := filepath.Join(t.TempDir(), "ca.crt")
require.NoError(t, os.WriteFile(caFile, filePEM, 0o600))
systemPool := x509.NewCertPool()
require.True(t, systemPool.AppendCertsFromPEM(systemPEM))
for _, server := range []struct {
name string
cert tls.Certificate
}{
{name: "system CA", cert: systemCert},
{name: "file CA", cert: fileCert},
{name: "untrusted CA", cert: untrustedCert},
} {
t.Run(server.name, func(t *testing.T) {
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{server.cert}})
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_ = conn.(*tls.Conn).Handshake()
_ = conn.Close()
}
}()
for _, test := range []struct {
name string
caPath string
useSystemCa bool
serverName string
wantErr bool
}{
{name: "no trust source"},
{name: "file CA only", caPath: caFile, wantErr: server.name != "file CA"},
// All certificates were just generated and are absent from the real
// host store, even on hosts without any installed system roots.
{name: "system CA only rejects unknown certificate", useSystemCa: true, wantErr: true},
{name: "combined CAs", caPath: caFile, useSystemCa: true, wantErr: server.name == "untrusted CA"},
{
name: "combined CAs reject wrong name", caPath: caFile, useSystemCa: true,
serverName: "other.example.com", wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
serverName := test.serverName
if serverName == "" {
serverName = "localhost"
}
var cfg *tls.Config
var err error
if test.caPath != "" && test.useSystemCa {
cfg, err = newClientTLSConfig("", "", test.caPath, serverName, true,
func() (*x509.CertPool, error) { return systemPool.Clone(), nil })
} else {
cfg, err = NewClientTLSConfigWithSystemCA("", "", test.caPath, serverName, test.useSystemCa)
}
require.NoError(t, err)
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", ln.Addr().String(), cfg)
if conn != nil {
_ = conn.Close()
}
if test.wantErr {
var verificationErr *tls.CertificateVerificationError
require.ErrorAs(t, err, &verificationErr)
} else {
require.NoError(t, err)
}
})
}
})
}
}
-18
View File
@@ -1,18 +0,0 @@
package banner
import (
"fmt"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
)
func DisplayBanner() {
fmt.Println(" __ ___ __________ ____ ________ ____")
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
fmt.Println(" ")
log.Infof("Nya! %s 启动中", version.Full())
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"net/http"
"net/http/pprof"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// NewMetricsServer creates a listener with only health, optional metrics, and
// opt-in profiling endpoints. A zero port disables it and returns a nil server.
func NewMetricsServer(cfg v1.MetricsServerConfig, metricsHandler http.Handler) (*Server, error) {
if cfg.Port == 0 {
return nil, nil
}
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
if err != nil {
return nil, err
}
s.router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
authed := s.router.NewRoute().Subrouter()
authed.Use(s.authMiddleware)
if metricsHandler != nil {
authed.Handle("/metrics", metricsHandler)
}
if cfg.PprofEnable {
// Use exact routes rather than a prefix so cmdline and unknown endpoints
// cannot reach pprof.Index's fallback dispatch.
authed.HandleFunc("/debug/pprof/", pprof.Index)
authed.HandleFunc("/debug/pprof/profile", pprof.Profile)
authed.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
authed.HandleFunc("/debug/pprof/trace", pprof.Trace)
for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} {
authed.Handle("/debug/pprof/"+name, pprof.Handler(name))
}
}
return s, nil
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/assets"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func newTestMetricsServer(t *testing.T, pprofEnabled bool, handler http.Handler) *Server {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
s, err := NewMetricsServer(v1.MetricsServerConfig{
Addr: "127.0.0.1", Port: port, User: "monitor", Password: "secret", PprofEnable: pprofEnabled,
}, handler)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, s.Close()) })
return s
}
func TestMetricsServerRoutes(t *testing.T) {
for _, enabled := range []bool{false, true} {
name := "pprof disabled"
if enabled {
name = "pprof enabled"
}
t.Run(name, func(t *testing.T) {
calls := 0
s := newTestMetricsServer(t, enabled, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
_, _ = w.Write([]byte("business metrics"))
}))
for _, authed := range []bool{false, true} {
for _, path := range []string{
"/healthz", "/metrics", "/debug/pprof/", "/debug/pprof/goroutine",
"/debug/pprof/cmdline", "/debug/pprof/unknown", "/", "/static/app.js", "/api/serverinfo", "/api/config",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
if authed {
req.SetBasicAuth("monitor", "secret")
}
resp := httptest.NewRecorder()
s.router.ServeHTTP(resp, req)
expected := http.StatusNotFound
switch path {
case "/healthz":
expected = http.StatusOK
require.Empty(t, resp.Body.String())
case "/metrics":
expected = http.StatusUnauthorized
if authed {
expected = http.StatusOK
require.Equal(t, "business metrics", resp.Body.String())
}
case "/debug/pprof/", "/debug/pprof/goroutine":
if enabled {
expected = http.StatusUnauthorized
if authed {
expected = http.StatusOK
}
}
}
require.Equal(t, expected, resp.Code, "path=%s authenticated=%v", path, authed)
}
}
require.Equal(t, 1, calls, "unauthenticated requests must not reach the metrics handler")
})
}
}
func TestMetricsServerWithoutMetricsPreservesAssets(t *testing.T) {
assets.Load(t.TempDir())
before := assets.FileSystem
t.Cleanup(func() { assets.Load("") })
s := newTestMetricsServer(t, false, nil)
// assets.Load uses http.Dir for local assets, a value rather than a pointer.
require.Equal(t, before, assets.FileSystem)
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.SetBasicAuth("monitor", "secret")
resp := httptest.NewRecorder()
s.router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
}
func TestMetricsServerDisabled(t *testing.T) {
s, err := NewMetricsServer(v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "nonexistent"}}, nil)
require.NoError(t, err)
require.Nil(t, s)
}
+22 -13
View File
@@ -48,15 +48,21 @@ type Server struct {
func NewServer(cfg v1.WebServerConfig) (*Server, error) {
assets.Load(cfg.AssetsDir)
addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port))
if addr == ":" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
if err != nil {
return nil, err
}
if cfg.PprofEnable {
s.registerPprofHandlers()
}
return s, nil
}
func newServer(bindAddr string, port int, user, password string, tlsConfig *v1.TLSConfig) (*Server, error) {
addr := net.JoinHostPort(bindAddr, strconv.Itoa(port))
if addr == ":" {
addr = ":http"
}
router := mux.NewRouter()
hs := &http.Server{
@@ -67,15 +73,11 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
}
s := &Server{
addr: addr,
ln: ln,
hs: hs,
router: router,
}
if cfg.PprofEnable {
s.registerPprofHandlers()
}
if cfg.TLS != nil {
cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
if tlsConfig != nil {
cert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile)
if err != nil {
return nil, err
}
@@ -83,7 +85,14 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
Certificates: []tls.Certificate{cert},
}
}
s.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.Password).SetAuthFailDelay(200 * time.Millisecond).Middleware
s.authMiddleware = netpkg.NewHTTPAuthMiddleware(user, password).SetAuthFailDelay(200 * time.Millisecond).Middleware
// Bind only after all fallible preparation so an error cannot leak the listener.
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s.ln = ln
return s, nil
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"net"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewServerDoesNotLeakListenerOnTLSFailure(t *testing.T) {
dir := t.TempDir()
certFile := filepath.Join(dir, "cert.pem")
keyFile := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
address := ln.Addr().String()
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
svr, err := NewServer(v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
TLS: &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile},
})
if svr != nil {
defer svr.Close()
}
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
require.Nil(t, svr)
ln, err = net.Listen("tcp", address)
require.NoError(t, err, "web server port was not released")
require.NoError(t, ln.Close())
}
+27 -157
View File
@@ -16,18 +16,13 @@ package log
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/fatedier/golib/log"
)
var (
TraceLevel = log.DebugLevel
TraceLevel = log.TraceLevel
DebugLevel = log.DebugLevel
InfoLevel = log.InfoLevel
WarnLevel = log.WarnLevel
@@ -37,157 +32,39 @@ var (
var Logger *log.Logger
func init() {
Logger = log.NewWithOptions(os.Stderr, log.Options{
ReportCaller: true,
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "LoliaFRP-CLI",
CallerOffset: 1,
})
// 设置自定义样式以支持 Trace 级别
styles := log.DefaultStyles()
styles.Levels[TraceLevel] = lipgloss.NewStyle().
SetString("TRACE").
Bold(true).
MaxWidth(5).
Foreground(lipgloss.Color("61"))
Logger.SetStyles(styles)
Logger = log.New(
log.WithCaller(true),
log.AddCallerSkip(1),
log.WithLevel(log.InfoLevel),
)
}
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
var output io.Writer
var err error
options := []log.Option{}
if logPath == "console" {
output = os.Stdout
} else {
// Use rotating file writer
output, err = NewRotateFileWriter(logPath, maxDays)
if err != nil {
// Fallback to console if file creation fails
output = os.Stdout
if !disableLogColor {
options = append(options,
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
Colorful: true,
}, os.Stdout)),
)
}
} else {
writer := log.NewRotateFileWriter(log.RotateFileConfig{
FileName: logPath,
Mode: log.RotateFileModeDaily,
MaxDays: maxDays,
})
writer.Init()
options = append(options, log.WithOutput(writer))
}
level, err := log.ParseLevel(levelStr)
if err != nil {
level = log.InfoLevel
}
Logger = log.NewWithOptions(output, log.Options{
ReportCaller: true,
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "LoliaFRP-CLI",
CallerOffset: 1,
Level: level,
})
}
// NewRotateFileWriter creates a rotating file writer
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
w := &RotateFileWriter{
filePath: filePath,
maxDays: maxDays,
lastRotate: time.Now(),
currentDate: time.Now().Format("2006-01-02"),
}
if err := w.openFile(); err != nil {
return nil, err
}
return w, nil
}
// RotateFileWriter implements io.Writer with daily rotation
type RotateFileWriter struct {
filePath string
maxDays int
file *os.File
lastRotate time.Time
currentDate string
}
func (w *RotateFileWriter) openFile() error {
var err error
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
return err
}
func (w *RotateFileWriter) checkRotate() error {
now := time.Now()
currentDate := now.Format("2006-01-02")
if currentDate != w.currentDate {
// Close current file
if w.file != nil {
w.file.Close()
}
// Rename current file with date suffix
oldPath := w.filePath
newPath := w.filePath + "." + w.currentDate
if _, err := os.Stat(oldPath); err == nil {
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
}
// Clean up old log files
w.cleanupOldLogs(now)
// Update current date and open new file
w.currentDate = currentDate
w.lastRotate = now
return w.openFile()
}
return nil
}
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
if w.maxDays <= 0 {
return
}
cutoffDate := now.AddDate(0, 0, -w.maxDays)
// Find and remove old log files
dir := filepath.Dir(w.filePath)
base := filepath.Base(w.filePath)
files, _ := os.ReadDir(dir)
for _, f := range files {
if f.IsDir() {
continue
}
name := f.Name()
// Extract date from filename (base.YYYY-MM-DD)
if dateStr, ok := strings.CutPrefix(name, base+"."); ok {
if len(dateStr) == 10 {
fileDate, err := time.Parse("2006-01-02", dateStr)
if err == nil && fileDate.Before(cutoffDate) {
os.Remove(filepath.Join(dir, name))
}
}
}
}
}
func (w *RotateFileWriter) Write(p []byte) (n int, err error) {
if err := w.checkRotate(); err != nil {
return 0, err
}
return w.file.Write(p)
}
func (w *RotateFileWriter) Close() error {
if w.file != nil {
return w.file.Close()
}
return nil
options = append(options, log.WithLevel(level))
Logger = Logger.WithOptions(options...)
}
func Errorf(format string, v ...any) {
@@ -198,10 +75,6 @@ func Warnf(format string, v ...any) {
Logger.Warnf(format, v...)
}
func Info(format string, v ...any) {
Logger.Info(format, v...)
}
func Infof(format string, v ...any) {
Logger.Infof(format, v...)
}
@@ -211,12 +84,11 @@ func Debugf(format string, v ...any) {
}
func Tracef(format string, v ...any) {
Logger.Logf(TraceLevel, format, v...)
Logger.Tracef(format, v...)
}
func Logf(level log.Level, offset int, format string, v ...any) {
// charmbracelet/log doesn't support offset, so we ignore it
Logger.Logf(level, format, v...)
Logger.Logf(level, offset, format, v...)
}
type WriteLogger struct {
@@ -232,8 +104,6 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
}
func (w *WriteLogger) Write(p []byte) (n int, err error) {
// charmbracelet/log doesn't support offset in Log
msg := string(bytes.TrimRight(p, "\n"))
Logger.Log(w.level, msg)
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
return len(p), nil
}
+6 -5
View File
@@ -58,7 +58,7 @@ func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock)
reserveDays: reserveDays,
counts: make([]int64, reserveDays),
clock: clk,
lastUpdateDate: startOfDay(clk.Now()),
lastUpdateDate: calendarDate(clk.Now()),
}
}
@@ -114,7 +114,7 @@ func (c *StandardDateCounter) Clear() {
// rotate
// Must hold the lock before calling this function.
func (c *StandardDateCounter) rotate(now time.Time) {
now = startOfDay(now)
now = calendarDate(now)
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
reserveDays := int(c.reserveDays)
@@ -132,7 +132,8 @@ func (c *StandardDateCounter) rotate(now time.Time) {
c.lastUpdateDate = now
}
// startOfDay returns midnight in t's location.
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
// calendarDate represents t's local date as midnight UTC for day arithmetic.
// This avoids variable day lengths and nonexistent local midnights during DST.
func calendarDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
+64 -4
View File
@@ -31,7 +31,7 @@ func TestDateCounter(t *testing.T) {
func TestDateCounterRotate(t *testing.T) {
loc := time.FixedZone("test", 8*60*60)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
@@ -55,19 +55,19 @@ func TestDateCounterRotate(t *testing.T) {
name: "one day",
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
want: []int64{0, 10, 7},
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, time.UTC),
},
{
name: "two days",
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
want: []int64{0, 0, 10},
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, time.UTC),
},
{
name: "all reserved days elapsed",
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
want: []int64{0, 0, 0},
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, time.UTC),
},
}
@@ -132,3 +132,63 @@ func TestDateCounterConcurrentAccess(t *testing.T) {
}
wg.Wait()
}
func TestDateCounterCalendarDaysAcrossDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
midnightLoc, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
tests := []struct {
name string
start time.Time
end time.Time
want []int64
}{
{
name: "spring next day",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 9, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "spring two days",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 10, 12, 0, 0, 0, loc),
want: []int64{0, 0, 10},
},
{
name: "spring retention expiry",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 11, 12, 0, 0, 0, loc),
want: []int64{0, 0, 0},
},
{
name: "fall next day",
start: time.Date(2026, time.November, 1, 12, 0, 0, 0, loc),
end: time.Date(2026, time.November, 2, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "skipped midnight",
start: time.Date(2018, time.November, 3, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
{
name: "day after skipped midnight",
start: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 5, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clk := clocktesting.NewFakeClock(tt.start)
dc := newStandardDateCounterWithClock(3, clk)
dc.Inc(10)
clk.SetTime(tt.end)
require.Equal(t, tt.want[0], dc.TodayCount())
require.Equal(t, tt.want, dc.GetLastDaysCount(3))
})
}
}
-6
View File
@@ -17,15 +17,9 @@ package net
import (
"context"
"net"
"strings"
)
func SetDefaultDNSAddress(dnsAddress string) {
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
if strings.HasPrefix(dnsAddress, "https://") {
SetDefaultDNSOverHTTPS(dnsAddress)
return
}
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
dnsAddress = net.JoinHostPort(dnsAddress, "53")
}
-181
View File
@@ -1,181 +0,0 @@
// Copyright 2026 The Lolia Team
//
// 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 net
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
)
const (
dohMimeType = "application/dns-message"
dohMaxResponseSize = 65535
dohRequestTimeout = 10 * time.Second
)
// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends
// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint,
// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query".
func SetDefaultDNSOverHTTPS(dohURL string) {
client := &http.Client{
Timeout: dohRequestTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: dohRequestTimeout,
// Use a fresh resolver to look up the DoH server hostname itself,
// avoiding infinite recursion through net.DefaultResolver.
Resolver: &net.Resolver{},
}).DialContext,
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ForceAttemptHTTP2: true,
},
}
net.DefaultResolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
return &dohConn{
ctx: ctx,
client: client,
url: dohURL,
network: network,
}, nil
},
}
}
// dohConn adapts the DNS wire-format messages exchanged by net.Resolver
// into DNS-over-HTTPS requests. Since dohConn does not implement
// net.PacketConn, the resolver always uses stream (TCP-style) framing with a
// 2-byte big-endian length prefix, regardless of the dialed network. The
// resolver writes a query and then reads the response from the same
// connection; the HTTP round trip happens synchronously inside Write.
type dohConn struct {
ctx context.Context
client *http.Client
url string
network string
mu sync.Mutex
deadline time.Time
reqBuf bytes.Buffer // unprocessed request bytes
respBuf bytes.Buffer // response stream with 2-byte length prefixes
}
func (c *dohConn) Write(b []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.reqBuf.Write(b)
for {
data := c.reqBuf.Bytes()
if len(data) < 2 {
break
}
msgLen := int(binary.BigEndian.Uint16(data))
if len(data) < 2+msgLen {
break
}
msg := make([]byte, msgLen)
copy(msg, data[2:2+msgLen])
c.reqBuf.Next(2 + msgLen)
resp, err := c.roundTrip(msg)
if err != nil {
return 0, err
}
var lenBuf [2]byte
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp)))
c.respBuf.Write(lenBuf[:])
c.respBuf.Write(resp)
}
return len(b), nil
}
func (c *dohConn) Read(b []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.respBuf.Len() == 0 {
return 0, io.EOF
}
return c.respBuf.Read(b)
}
func (c *dohConn) roundTrip(query []byte) ([]byte, error) {
ctx := c.ctx
if !c.deadline.IsZero() {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, c.deadline)
defer cancel()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", dohMimeType)
req.Header.Set("Accept", dohMimeType)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1))
if err != nil {
return nil, err
}
if len(body) > dohMaxResponseSize {
return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize)
}
return body, nil
}
func (c *dohConn) Close() error { return nil }
func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} }
func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} }
func (c *dohConn) SetDeadline(t time.Time) error {
c.mu.Lock()
defer c.mu.Unlock()
c.deadline = t
return nil
}
func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) }
type dohAddr struct {
network string
addr string
}
func (a *dohAddr) Network() string { return a.network }
func (a *dohAddr) String() string { return a.addr }
+32 -13
View File
@@ -15,16 +15,21 @@
package net
import (
"fmt"
"errors"
"net"
"sync"
kcp "github.com/xtaci/kcp-go/v5"
)
var ErrKCPListenerClosed = errors.New("kcp listener closed")
type KCPListener struct {
listener net.Listener
acceptCh chan net.Conn
closeFlag bool
done chan struct{}
closeOnce sync.Once
}
func ListenKcp(address string) (l *KCPListener, err error) {
@@ -38,19 +43,20 @@ func ListenKcp(address string) (l *KCPListener, err error) {
l = &KCPListener{
listener: listener,
acceptCh: make(chan net.Conn),
closeFlag: false,
done: make(chan struct{}),
}
go func() {
for {
conn, err := listener.AcceptKCP()
if err != nil {
if l.closeFlag {
close(l.acceptCh)
select {
case <-l.done:
return
}
default:
continue
}
}
conn.SetStreamMode(true)
conn.SetWriteDelay(true)
conn.SetNoDelay(1, 20, 2, 1)
@@ -58,25 +64,38 @@ func ListenKcp(address string) (l *KCPListener, err error) {
conn.SetWindowSize(1024, 1024)
conn.SetACKNoDelay(false)
l.acceptCh <- conn
select {
case <-l.done:
conn.Close()
return
case l.acceptCh <- conn:
// A ready handoff may win when Close races with it. Once delivered,
// the connection is owned by the caller of Accept.
}
}
}()
return l, err
}
func (l *KCPListener) Accept() (net.Conn, error) {
conn, ok := <-l.acceptCh
if !ok {
return conn, fmt.Errorf("channel for kcp listener closed")
select {
case <-l.done:
return nil, ErrKCPListenerClosed
default:
}
select {
case <-l.done:
return nil, ErrKCPListenerClosed
case conn := <-l.acceptCh:
return conn, nil
}
}
func (l *KCPListener) Close() error {
if !l.closeFlag {
l.closeFlag = true
l.closeOnce.Do(func() {
close(l.done)
l.listener.Close()
}
})
return nil
}
+131
View File
@@ -0,0 +1,131 @@
// 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 net
import (
"bytes"
"errors"
"net"
"runtime/pprof"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
kcp "github.com/xtaci/kcp-go/v5"
)
func kcpGoroutineDump(t *testing.T) string {
t.Helper()
var buf bytes.Buffer
require.NoError(t, pprof.Lookup("goroutine").WriteTo(&buf, 2))
return buf.String()
}
func TestKCPListenerCloseCancelsPendingHandoff(t *testing.T) {
l, err := ListenKcp("127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = l.Close()
// Also release a stuck handoff when this test runs against the old code.
select {
case conn := <-l.acceptCh:
if conn != nil {
_ = conn.Close()
}
default:
}
})
sessionsBefore := atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab)
peer, err := kcp.DialWithOptions(l.Addr().String(), nil, 10, 3)
require.NoError(t, err)
t.Cleanup(func() { _ = peer.Close() })
peer.SetNoDelay(1, 10, 2, 1)
require.NoError(t, peer.SetWriteDeadline(time.Now().Add(5*time.Second)))
_, err = peer.Write([]byte("pending handoff"))
require.NoError(t, err)
var loopID string
require.Eventually(t, func() bool {
for stack := range strings.SplitSeq(kcpGoroutineDump(t), "\n\n") {
if !strings.Contains(stack, "github.com/fatedier/frp/pkg/util/net.ListenKcp.func1()") ||
strings.Contains(stack, "github.com/xtaci/kcp-go/v5.(*Listener).AcceptKCP(") {
continue
}
header, _, _ := strings.Cut(stack, "\n")
if strings.Contains(header, "[select]") || strings.Contains(header, "[chan send]") {
loopID = strings.Fields(header)[1]
return true
}
}
return false
}, 5*time.Second, time.Millisecond, "accept loop did not reach the unconsumed handoff")
require.Equal(t, sessionsBefore+2, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
require.NoError(t, l.Close())
require.Eventually(t, func() bool {
return !strings.Contains(kcpGoroutineDump(t), "goroutine "+loopID+" [")
}, 5*time.Second, time.Millisecond, "accept loop remained blocked after Close")
// kcp-go has no close frame. Its session counter decreases only when the
// session itself is closed; closing the listener socket is not sufficient.
// Keep the client open so this decrement must belong to the pending server session.
require.Equal(t, sessionsBefore+1, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
require.NoError(t, peer.Close())
require.Equal(t, sessionsBefore, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
}
func TestKCPListenerAcceptUnblocksOnClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
l := &KCPListener{
listener: &closeStubListener{closed: make(chan struct{}), err: errors.New("listener close failed")},
acceptCh: make(chan net.Conn),
done: make(chan struct{}),
}
t.Cleanup(func() { _ = l.Close() })
acceptErr := make(chan error, 1)
go func() {
conn, err := l.Accept()
if conn != nil {
t.Error("Accept returned a connection without a sender")
}
acceptErr <- err
}()
synctest.Wait() // Accept is blocked before Close.
require.Empty(t, acceptErr)
var wg sync.WaitGroup
for range 2 {
wg.Go(func() {
if err := l.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
wg.Wait()
synctest.Wait()
require.Len(t, acceptErr, 1)
require.ErrorIs(t, <-acceptErr, ErrKCPListenerClosed)
require.NoError(t, l.Close())
conn, err := l.Accept()
require.Nil(t, conn)
require.ErrorIs(t, err, ErrKCPListenerClosed)
})
}
+22 -3
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/websocket"
@@ -18,8 +19,11 @@ const (
type WebsocketListener struct {
ln net.Listener
acceptCh chan net.Conn
done chan struct{}
server *http.Server
closeOnce sync.Once
}
// NewWebsocketListener to handle websocket connections
@@ -28,6 +32,7 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
wl = &WebsocketListener{
ln: ln,
acceptCh: make(chan net.Conn),
done: make(chan struct{}),
}
muxer := http.NewServeMux()
@@ -41,7 +46,14 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
conn := WrapCloseNotifyConn(c, func(_ error) {
close(notifyCh)
})
wl.acceptCh <- conn
select {
case <-wl.done:
conn.Close()
return
case wl.acceptCh <- conn:
// A ready handoff may win when Close races with it. Once delivered,
// the connection is owned by the caller of Accept.
}
<-notifyCh
}))
@@ -58,14 +70,21 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
}
func (p *WebsocketListener) Accept() (net.Conn, error) {
c, ok := <-p.acceptCh
if !ok {
select {
case <-p.done:
return nil, ErrWebsocketListenerClosed
default:
}
select {
case <-p.done:
return nil, ErrWebsocketListenerClosed
case c := <-p.acceptCh:
return c, nil
}
}
func (p *WebsocketListener) Close() error {
p.closeOnce.Do(func() { close(p.done) })
return p.server.Close()
}
+197
View File
@@ -0,0 +1,197 @@
// 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 net
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/websocket"
)
type closeStubListener struct {
closed chan struct{}
once sync.Once
err error
}
func (l *closeStubListener) Accept() (net.Conn, error) {
<-l.closed
return nil, net.ErrClosed
}
func (l *closeStubListener) Close() error {
l.once.Do(func() { close(l.closed) })
return l.err
}
func (l *closeStubListener) Addr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
}
func TestWebsocketListenerAcceptUnblocksOnClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
t.Cleanup(func() { wl.Close() })
errCh := make(chan error, 3)
for range 3 {
go func() {
c, err := wl.Accept()
if c != nil {
t.Error("Accept returned a connection without a sender")
}
errCh <- err
}()
}
synctest.Wait() // All Accept calls are blocked before Close.
require.Empty(t, errCh)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
if err := wl.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
wg.Wait()
synctest.Wait()
require.Len(t, errCh, 3)
for range 3 {
require.ErrorIs(t, <-errCh, ErrWebsocketListenerClosed)
}
c, err := wl.Accept()
require.Nil(t, c)
require.ErrorIs(t, err, ErrWebsocketListenerClosed)
})
}
func TestWebsocketListenerCloseError(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
closeErr := errors.New("listener close failed")
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{}), err: closeErr})
synctest.Wait() // Let Serve register its listener before Close.
require.ErrorIs(t, wl.Close(), closeErr)
require.NoError(t, wl.Close())
})
}
type websocketPipeHijacker struct {
*httptest.ResponseRecorder
conn net.Conn
rw *bufio.ReadWriter
}
func (h *websocketPipeHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return h.conn, h.rw, nil
}
func newWebsocketPipe(t *testing.T) (*WebsocketListener, *websocket.Conn, net.Conn, <-chan struct{}) {
t.Helper()
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
wl.Close()
serverConn.Close()
clientConn.Close()
})
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
rw := bufio.NewReadWriter(bufio.NewReader(serverConn), bufio.NewWriter(serverConn))
req, err := http.ReadRequest(rw.Reader)
if err != nil {
t.Errorf("ReadRequest: %v", err)
return
}
wl.server.Handler.ServeHTTP(&websocketPipeHijacker{httptest.NewRecorder(), serverConn, rw}, req)
}()
cfg, err := websocket.NewConfig("ws://localhost"+FrpWebsocketPath, "http://localhost")
require.NoError(t, err)
peer, err := websocket.NewClient(cfg, clientConn)
require.NoError(t, err)
return wl, peer, serverConn, handlerDone
}
func TestWebsocketListenerUnacceptedConnectionClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
peerErr := make(chan error, 1)
go func() {
_, err := peer.Read(make([]byte, 1))
peerErr <- err
}()
// No Accept consumer exists. Do not Wait here: it would synchronize
// the handler's send with Close and hide the baseline send/close race.
require.NoError(t, wl.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err := raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
func TestWebsocketListenerAcceptedConnectionOwnership(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
owner, err := wl.Accept()
require.NoError(t, err)
t.Cleanup(func() {
raw.Close()
owner.Close()
})
require.NoError(t, wl.Close())
payload := []byte{0, 255, 128, 42}
reply := []byte("reply")
gotReply := make([]byte, len(reply))
peerErr := make(chan error, 2)
go func() {
if _, err := peer.Write(payload); err != nil {
peerErr <- err
return
}
_, err := io.ReadFull(peer, gotReply)
peerErr <- err
_, err = peer.Read(make([]byte, 1))
peerErr <- err
}()
got := make([]byte, len(payload))
_, err = io.ReadFull(owner, got)
require.NoError(t, err)
require.Equal(t, payload, got)
_, err = owner.Write(reply)
require.NoError(t, err)
require.NoError(t, <-peerErr)
require.Equal(t, reply, gotReply)
require.NoError(t, owner.Close())
require.NoError(t, owner.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err = raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "LoliaFRP-CLI 0.71.0"
var version = "0.71.1"
func Full() string {
return version

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