Compare commits

..
83 Commits
Author SHA1 Message Date
Mxmilu666 92b4df6dd9 chore(version): update version to LoliaFRP-CLI 0.71.0 2026-08-10 12:18:46 +08:00
Mxmilu666 10c313daa6 Merge remote-tracking branch 'upstream/dev' into dev
# Conflicts:
#	.github/workflows/build-and-push-image.yml
#	cmd/frpc/sub/verify.go
#	go.mod
#	go.sum
#	pkg/util/version/version.go
2026-08-10 11:55:54 +08:00
fatedierandGitHub 71a2bf30f9 docs: add release note for domain validation fix (#5475) 2026-08-09 23:04:09 +08:00
fatedierandGitHub a6a782bed4 config: reject case-insensitive subdomain domains (#5474) 2026-08-09 22:52:30 +08:00
fatedierandGitHub 223b44336c ssh: serialize tunnel channel writes (#5473) 2026-08-09 19:12:12 +08:00
fatedierandGitHub f6688e2a0d client: reject invalid work connection addresses (#5472) 2026-08-09 18:39:21 +08:00
fatedierandGitHub f666d97b64 limit: clamp bandwidth limiter burst (#5471) 2026-08-09 17:30:39 +08:00
fatedierandGitHub 5b68148f11 udp: handle closed forwarding channel (#5470) 2026-08-09 15:31:11 +08:00
Yarden ShohamandGitHub d1928f9689 remove retired Go Report Card badge (#5467) 2026-08-05 10:52:16 +08:00
fatedierandGitHub 1a3a872bd2 fix(frpc): respect feature gates in verify (#5465) 2026-08-04 12:53:10 +08:00
fatedierandGitHub 290017cb53 update version to 0.71.0 (#5464) 2026-08-03 23:37:36 +08:00
fatedierandGitHub 2291e8835f feat: use binary codec for SUDP packets (#5461) 2026-07-31 16:53:16 +08:00
fatedierandGitHub 1ab59e763c server: validate control pool counts (#5459) 2026-07-30 23:30:16 +08:00
fatedierandGitHub 9d45a55720 docs: add release note for #5456 (#5458) 2026-07-30 14:40:54 +08:00
fatedierandGitHub effa496859 feat: add negotiated binary UDP packets (#5456) 2026-07-30 13:19:55 +08:00
fatedierandGitHub 5c6d761c12 chore(web): upgrade frontend dependencies (#5454) 2026-07-28 19:36:44 +08:00
fatedierandGitHub 3d89ab7ff1 build(web): upgrade auto import plugins (#5453) 2026-07-28 17:09:46 +08:00
fatedierandGitHub f3828cbfdb build(web): upgrade ESLint to v10 (#5452) 2026-07-28 16:10:29 +08:00
fatedierandGitHub 00c24e39bb test(web): add frontend unit test baseline (#5451) 2026-07-28 01:27:35 +08:00
fatedierandGitHub 2175557d48 web: fix Vite config tsconfig includes (#5450) 2026-07-27 19:21:28 +08:00
fatedierandGitHub de4b483710 web: patch vulnerable transitive dependencies (#5449) 2026-07-27 14:57:58 +08:00
fatedierandGitHub 2d63f6b1f9 web/frpc: fix visitor plugin validation for sudp (#5448) 2026-07-27 12:04:00 +08:00
barkureandGitHub 40274d8c92 web/frpc: support virtual net visitor plugin (#5414) 2026-07-23 22:02:49 +08:00
Mxmilu666 0ff5557571 chore(version): update version to LoliaFRP-CLI 0.69.3 2026-07-12 04:09:13 +08:00
Mxmilu666 bce3d5a3d8 chore(deps): use Lolia-FRP golib fork with adaptive buffer io.Join 2026-07-05 04:55:22 +08:00
Mxmilu666 33a29b04e4 fix(redirect): improve security in HTTP to HTTPS redirection 2026-07-04 14:39:41 +08:00
Mxmilu666 481ccad05e chore(version): update version to LoliaFRP-CLI 0.69.2 2026-07-04 14:32:44 +08:00
Mxmilu666 521542b796 feat(proxy): add HTTP to HTTPS redirection support 2026-07-04 14:30:52 +08:00
Mxmilu666 e487e7f96f chore(build): add support for linux/arm/v7 platform 2026-06-23 19:53:03 +08:00
Mxmilu666 2f73a38a2f fix(build): add linkname check for Android builds 2026-06-21 14:10:51 +08:00
Mxmilu666 a9eb5e6901 fix(build): disable linkname check for Android builds 2026-06-21 14:04:23 +08:00
Mxmilu666 386df7f426 chore(go.mod): update golib dependency to v0.7.4 2026-06-21 13:52:28 +08:00
Mxmilu666 79d5cc00ab chore(go.mod): update golib dependency to v0.7.4 2026-06-21 13:51:50 +08:00
Mxmilu666 d43a4e8950 Merge remote-tracking branch 'upstream/dev' into dev 2026-06-21 13:51:26 +08:00
Mxmilu666 d205f2bb35 chore(ci): update Go version and golangci-lint version
feat(linter): add new linters G118 and G704
refactor(proxy): optimize proxy status slice initialization
fix(api): clarify comment regarding URL construction
fix(deps): upgrade github.com/hashicorp/yamux to v0.1.2
docs(flags): improve DNS server flag description
refactor(controller): simplify port range return logic
refactor(featuregate): optimize enabled features string conversion
2026-06-06 11:35:40 +08:00
Mxmilu666 a2b9062f2c feat(dns): add support for DNS-over-HTTPS and update version 2026-06-06 11:12:05 +08:00
Mxmilu666 26f3af3fdd fix(deps): downgrade github.com/hashicorp/yamux to v0.1.1 2026-05-07 19:45:41 +08:00
Mxmilu666 7d79b83a39 chore(deps): update Go modules and package-lock files 2026-05-07 18:28:10 +08:00
Mxmilu666 e3ea471e44 feat(build): add support for loong64 architecture 2026-04-25 17:51:35 +08:00
Mxmilu666 e66f45d8be feat(plugin): add ReadHeaderTimeout to HTTP2HTTPS redirect 2026-03-10 19:08:38 +08:00
Mxmilu666 cc0b8d0f94 chore(version): update version to 0.67.4 2026-03-10 18:26:34 +08:00
Mxmilu666 27237542c8 feat(plugin): add HTTP2HTTPS redirect plugin implementation 2026-03-10 18:26:20 +08:00
Mxmilu666 406ea5ebee chore(version): update version to 0.67.3 2026-03-08 18:35:40 +08:00
Mxmilu666 81a92d19d5 feat(proxy): add traffic counting wrapper for connections 2026-03-08 18:33:42 +08:00
Mxmilu666 83847f32ed feat(controller): add API to close proxy by name 2026-02-21 07:42:17 +08:00
Mxmilu666 1f2c26761d refactor(release): update changelog generation process 2026-02-20 15:28:31 +08:00
Mxmilu666 c836e276a7 style(plugin): reorder import statements for clarity 2026-02-20 15:15:16 +08:00
Mxmilu666 d16f07d3e8 Merge pull request 'feat(proxy): add AutoTLS support for HTTPS plugins' (#1) from https-acme into dev
Reviewed-on: #1
2026-02-20 15:07:59 +08:00
Mxmilu666 6bd5eb92c3 feat(proxy): add AutoTLS support for HTTPS plugins 2026-02-19 09:01:07 +08:00
Mxmilu666 09602f5d74 chore(gitattributes): add .gitattributes for line endings 2026-02-19 06:37:58 +08:00
Mxmilu666 d7559b39e2 Merge remote-tracking branch 'upstream/dev' into dev 2026-02-08 00:57:08 +08:00
Mxmilu666 72d147dfa5 fix(workflows): add missing architectures for build matrix 2026-02-04 19:32:24 +08:00
Mxmilu666 481121a6c2 refactor(workflows): inline image name definitions in docker-build.yml 2026-01-16 00:20:37 +08:00
Mxmilu666 be252de683 chore(workflows): remove old build and push workflow file
fix(dockerfiles): update base image to golang:1.25 for frpc and frps
2026-01-16 00:19:10 +08:00
Mxmilu666 0a99c1071b feat(release): add debug step to check tags and commits[skip ci] 2026-01-14 00:30:05 +08:00
Mxmilu666 dd37b2e199 fix(frpc): fix goroutine closure in runMultipleClientsFromFiles 2026-01-14 00:11:41 +08:00
Mxmilu666 803e548f42 fix(version): update version to LoliaFRP-CLI 0.66.3 2026-01-14 00:03:50 +08:00
Mxmilu666 2dac44ac2e fix(config): support multiple configuration files for frpc 2026-01-14 00:03:18 +08:00
Mxmilu666 655dc3cb2a fix(version): update version to LoliaFRP-CLI 0.66.2 2026-01-13 00:10:24 +08:00
Mxmilu666 9894342f46 fix(auth): support multiple authentication tokens for frpc 2026-01-13 00:04:50 +08:00
Mxmilu666 e7cc706c86 fix(vhost): correct heading text for unbound domain page 2026-01-11 14:37:17 +08:00
Mxmilu666 92ac2b9153 fix(workflow): enhance artifact handling in release process [skip ci] 2026-01-11 14:08:20 +08:00
Mxmilu666 1ed369e962 fix(workflow): remove tags specification from push event [skip ci] 2026-01-11 14:04:37 +08:00
Mxmilu666 b74a8d0232 fix(workflow): remove branch specification from build-all workflow 2026-01-11 14:01:35 +08:00
Mxmilu666 d2180081a0 fix(workflow): correct path to build-all workflow 2026-01-11 13:58:22 +08:00
Mxmilu666 51f4e065b5 fix(workflow): correct path to build-all workflow 2026-01-11 13:55:26 +08:00
Mxmilu666 e58f774086 fix(workflow): specify branch for build-all workflow 2026-01-11 13:54:26 +08:00
Mxmilu666 178e381a26 fix(workflow): update build-all workflow path to absolute 2026-01-11 13:53:33 +08:00
Mxmilu666 26b93ae3a3 chore(version): update version to LoliaFRP-CLI 0.66.1 2026-01-11 13:52:10 +08:00
Mxmilu666 a2aeee28e4 feat(vhost): enhance Not Found page with Chinese localization 2026-01-11 13:51:37 +08:00
Mxmilu666 0416caef71 feat(workflow): enable workflow_call for build-all workflow [skip ci] 2026-01-11 13:36:14 +08:00
Mxmilu666 1004473e42 feat(workflow): add release workflow for FRP binaries 2026-01-11 13:30:40 +08:00
Mxmilu666 f386996928 fix(api): update comment for URL construction security 2026-01-11 04:05:27 +08:00
Mxmilu666 4eb4b202c5 fix(controller): include session ID in analysis error log
style(log): use octal notation for file permissions
2026-01-11 04:00:52 +08:00
Mxmilu666 ac5bdad507 feat(client): add access messages for proxy services
feat(client): translate log messages to Chinese
feat(cmd): add authentication token support for API config
feat(log): implement rotating file logger with custom styles
feat(banner): add banner display function
fix(version): update version string for CLI
2026-01-11 03:53:52 +08:00
Mxmilu666 42f4ea7f87 feat(build): enhance android-arm build process and output 2026-01-11 02:00:52 +08:00
Mxmilu666 36e5ac094b feat(build): add android-arm build process and dependencies 2026-01-11 01:56:30 +08:00
Mxmilu666 72f79d3357 feat(api): add endpoint to close proxy by name 2025-12-26 01:10:43 +08:00
Mxmilu666 e1f905f63f chore: remove obsolete build-all workflow and update architectures in package script 2025-12-25 22:19:53 +08:00
Mxmilu666 eb58f09268 chore: remove funding and pull request template files 2025-12-25 22:19:50 +08:00
Mxmilu666 46955ffc80 feat(api): update kick proxy endpoint to use name only 2025-12-25 22:19:00 +08:00
Mxmilu666 2d63296576 feat(api): add endpoint to retrieve all proxies 2025-12-25 22:18:38 +08:00
Mxmilu666 a76ba823ee feat(api): add endpoint to kick proxy by name 2025-12-25 22:16:41 +08:00
138 changed files with 8315 additions and 1550 deletions
+8 -7
View File
@@ -7,14 +7,15 @@ jobs:
steps:
- checkout
- run:
name: Build web assets (frps)
command: make install build
working_directory: web/frps
name: Test and build web assets
command: make web-ci
- run:
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
name: Check Go formatting and build binaries
command: |
set -e
make env fmt
git diff --exit-code
make build
- run: make alltest
workflows:
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
-4
View File
@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: [fatedier]
custom: ["https://afdian.com/a/fatedier"]
-3
View File
@@ -1,3 +0,0 @@
### WHY
<!-- author to complete -->
+194
View File
@@ -0,0 +1,194 @@
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
@@ -1,83 +0,0 @@
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
@@ -0,0 +1,82 @@
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 -6
View File
@@ -22,12 +22,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: Test and build web assets
run: make web-ci
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
+129
View File
@@ -0,0 +1,129 @@
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,6 +32,10 @@ node_modules/
*.swp
# AI
CLAUDE.md
.claude/
.sisyphus/
.superpowers/
# TLS
.autotls-cache
+7 -2
View File
@@ -1,11 +1,13 @@
export PATH := $(PATH):`go env GOPATH`/bin
export GO111MODULE=on
LDFLAGS := -s -w
# -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
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
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
all: env fmt web build
@@ -16,6 +18,9 @@ env:
web: frps-web frpc-web
web-ci:
cd web && npm ci && npm run lint:check --workspace frps && npm run lint:check --workspace frpc && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc
frps-web:
$(MAKE) -C web/frps build
+10 -10
View File
@@ -12,6 +12,16 @@ 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
@@ -29,16 +39,6 @@ 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?
+10 -11
View File
@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)
@@ -15,6 +14,16 @@ 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
@@ -32,16 +41,6 @@ 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
+7 -3
View File
@@ -1,5 +1,9 @@
## 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.
## Fixes
* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported.
* Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection.
* Fixed an SSH tunnel gateway panic when handling malformed exec requests.
* 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.
+254
View File
@@ -2,12 +2,16 @@ package client
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/vnet"
)
func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
@@ -22,6 +26,256 @@ func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
}
}
func newTestVirtualNetProxyConfig(name string) *v1.STCPProxyConfig {
return &v1.STCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: name,
Type: "stcp",
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginVirtualNet,
ClientPluginOptions: &v1.VirtualNetPluginOptions{Type: v1.PluginVirtualNet},
},
},
},
}
}
func newTestVirtualNetVisitorConfig(name string) *v1.STCPVisitorConfig {
return &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: name,
Type: "stcp",
ServerName: "vnet-server",
SecretKey: "secret",
BindPort: -1,
Plugin: v1.TypedVisitorPluginOptions{
Type: v1.VisitorPluginVirtualNet,
VisitorPluginOptions: &v1.VirtualNetVisitorPluginOptions{
Type: v1.VisitorPluginVirtualNet,
DestinationIP: "100.86.0.1",
},
},
},
}
}
func TestServiceConfigManagerReloadVirtualNetRuntimeDependency(t *testing.T) {
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
tests := []struct {
name string
startupVirtualNetAddr string
nextConfig string
wantRuntimeDependency bool
}{
{
name: "unrelated common config",
nextConfig: `serverAddr = "0.0.0.0"`,
},
{
name: "VirtualNet address without startup runtime",
nextConfig: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.4/24"
`,
wantRuntimeDependency: true,
},
{
name: "VirtualNet proxy without startup runtime",
nextConfig: `[[proxies]]
name = "vnet-proxy"
type = "stcp"
secretKey = "secret"
[proxies.plugin]
type = "virtual_net"
`,
wantRuntimeDependency: true,
},
{
name: "VirtualNet visitor without startup runtime",
nextConfig: `[[visitors]]
name = "vnet-visitor"
type = "stcp"
serverName = "vnet-server"
secretKey = "secret"
bindPort = -1
[visitors.plugin]
type = "virtual_net"
destinationIP = "100.86.0.1"
`,
wantRuntimeDependency: true,
},
{
name: "existing VirtualNet startup runtime",
startupVirtualNetAddr: "100.86.0.4/24",
nextConfig: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.5/24"
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
current := &v1.ClientCommonConfig{}
if tc.startupVirtualNetAddr != "" {
current.FeatureGates = map[string]bool{"VirtualNet": true}
current.VirtualNet.Address = tc.startupVirtualNetAddr
}
if err := current.Complete(); err != nil {
t.Fatalf("complete current config: %v", err)
}
configFile := filepath.Join(t.TempDir(), "frpc.toml")
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
svr := &Service{
common: current,
reloadCommon: current,
configFilePath: configFile,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
}
if tc.startupVirtualNetAddr != "" {
svr.vnetController = vnet.NewController(current.VirtualNet)
}
err := (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
if tc.wantRuntimeDependency {
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("reload config: %v", err)
}
if svr.common != current {
t.Fatal("reload should not replace startup common config")
}
if tc.startupVirtualNetAddr == "" && svr.vnetController != nil {
t.Fatal("reload should not enable startup-only VirtualNet runtime state")
}
})
}
}
func TestServiceConfigManagerReloadVirtualNetRuntimeDependencyUsesMergedSources(t *testing.T) {
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
tests := []struct {
name string
nextConfig string
storeProxy v1.ProxyConfigurer
storeVisitor v1.VisitorConfigurer
wantRuntimeDependency bool
wantProxyPlugin string
}{
{
name: "Store VirtualNet proxy is rejected",
nextConfig: `serverAddr = "0.0.0.0"`,
storeProxy: newTestVirtualNetProxyConfig("store-vnet"),
wantRuntimeDependency: true,
},
{
name: "Store VirtualNet visitor is rejected",
nextConfig: `serverAddr = "0.0.0.0"`,
storeVisitor: newTestVirtualNetVisitorConfig("store-vnet"),
wantRuntimeDependency: true,
},
{
name: "Store VirtualNet proxy overrides file proxy",
nextConfig: `[[proxies]]
name = "shared"
type = "tcp"
localPort = 10080
remotePort = 10081
`,
storeProxy: newTestVirtualNetProxyConfig("shared"),
wantRuntimeDependency: true,
},
{
name: "Store non-VirtualNet proxy overrides file VirtualNet proxy",
nextConfig: `[[proxies]]
name = "shared"
type = "stcp"
secretKey = "secret"
[proxies.plugin]
type = "virtual_net"
`,
storeProxy: newTestRawTCPProxyConfig("shared"),
wantProxyPlugin: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
current := &v1.ClientCommonConfig{}
if err := current.Complete(); err != nil {
t.Fatalf("complete current config: %v", err)
}
configFile := filepath.Join(t.TempDir(), "frpc.toml")
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
if tc.storeProxy != nil {
if err := storeSource.AddProxy(tc.storeProxy); err != nil {
t.Fatalf("add store proxy: %v", err)
}
}
if tc.storeVisitor != nil {
if err := storeSource.AddVisitor(tc.storeVisitor); err != nil {
t.Fatalf("add store visitor: %v", err)
}
}
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
aggregator.SetStoreSource(storeSource)
svr := &Service{
common: current,
reloadCommon: current,
configFilePath: configFile,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
storeSource: storeSource,
}
err = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
if tc.wantRuntimeDependency {
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("reload config: %v", err)
}
if len(svr.proxyCfgs) != 1 {
t.Fatalf("expected one applied proxy, got %d", len(svr.proxyCfgs))
}
if got := svr.proxyCfgs[0].GetBaseConfig().Plugin.Type; got != tc.wantProxyPlugin {
t.Fatalf("unexpected applied proxy plugin: %q", got)
}
})
}
}
func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
+50 -4
View File
@@ -16,7 +16,9 @@ package client
import (
"context"
"fmt"
"net"
"strings"
"sync/atomic"
"time"
@@ -47,6 +49,8 @@ type SessionContext struct {
Connector MessageConnector
// Virtual net controller
VnetController *vnet.Controller
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
UDPPacketCodec string
}
type Control struct {
@@ -92,9 +96,16 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
ctl.pm = proxy.NewManager(
ctl.ctx,
sessionCtx.Common,
sessionCtx.Auth.EncryptionKey(),
ctl.msgTransporter,
sessionCtx.VnetController,
sessionCtx.UDPPacketCodec,
)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController, sessionCtx.UDPPacketCodec)
return ctl, nil
}
@@ -160,9 +171,44 @@ 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] start error: %v", proxyName, err)
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
} else {
xl.Infof("[%s] start proxy success", proxyName)
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)
}
}
}
}
+9 -4
View File
@@ -99,6 +99,7 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro
Auth: d.auth,
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
VnetController: d.vnetController,
UDPPacketCodec: loginResult.udpPacketCodec,
}, nil
}
@@ -127,8 +128,9 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
}
type loginExchangeResult struct {
resp *msg.LoginResp
crypto *wire.CryptoContext
resp *msg.LoginResp
crypto *wire.CryptoContext
udpPacketCodec string
}
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
@@ -172,6 +174,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
}()
var cryptoContext *wire.CryptoContext
var udpPacketCodec string
if wireConn != nil {
serverHelloFrame, err := wireConn.ReadFrame()
if err != nil {
@@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
if err != nil {
return nil, err
}
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
}
var loginRespMsg msg.LoginResp
@@ -198,8 +202,9 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
return nil, err
}
return &loginExchangeResult{
resp: &loginRespMsg,
crypto: cryptoContext,
resp: &loginRespMsg,
crypto: cryptoContext,
udpPacketCodec: udpPacketCodec,
}, nil
}
+2
View File
@@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) {
defer sessionCtx.Connector.Close()
require.Equal(t, "run-v1", sessionCtx.RunID)
require.Empty(t, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load())
@@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
defer sessionCtx.Connector.Close()
require.Equal(t, "run-v2", sessionCtx.RunID)
require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load())
+125
View File
@@ -0,0 +1,125 @@
// 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 (
"context"
"encoding/binary"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
clientproxy "github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestControlPropagatesBinaryUDPPacketCodecToWorkConn(t *testing.T) {
echoConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { _ = echoConn.Close() })
echoDone := make(chan error, 1)
go func() {
buf := make([]byte, 64)
n, addr, err := echoConn.ReadFromUDP(buf)
if err == nil {
_, err = echoConn.WriteToUDP(buf[:n], addr)
}
echoDone <- err
}()
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
Method: v1.AuthMethodToken,
Token: "token",
})
require.NoError(t, err)
controlConn, controlPeer := net.Pipe()
t.Cleanup(func() {
_ = controlConn.Close()
_ = controlPeer.Close()
})
common := &v1.ClientCommonConfig{
Transport: v1.ClientTransportConfig{WireProtocol: wire.ProtocolV2},
UDPPacketSize: 1500,
}
ctl, err := NewControl(context.Background(), &SessionContext{
Common: common,
RunID: "binary-udp-test",
Conn: msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)),
Auth: authRuntime,
UDPPacketCodec: wire.UDPPacketCodecBinary,
})
require.NoError(t, err)
t.Cleanup(ctl.pm.Close)
echoAddr := echoConn.LocalAddr().(*net.UDPAddr)
proxyCfg := &v1.UDPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "udp",
Type: string(v1.ProxyTypeUDP),
ProxyBackend: v1.ProxyBackend{
LocalIP: "127.0.0.1",
LocalPort: echoAddr.Port,
},
},
}
ctl.pm.UpdateAll([]v1.ProxyConfigurer{proxyCfg})
require.Eventually(t, func() bool {
status, ok := ctl.pm.GetProxyStatus("udp")
return ok && status.Phase == clientproxy.ProxyPhaseWaitStart
}, time.Second, 10*time.Millisecond)
require.NoError(t, ctl.pm.StartProxy("udp", "", ""))
workClient, workServer := net.Pipe()
t.Cleanup(func() {
_ = workClient.Close()
_ = workServer.Close()
})
deadline := time.Now().Add(3 * time.Second)
require.NoError(t, workClient.SetDeadline(deadline))
require.NoError(t, workServer.SetDeadline(deadline))
ctl.pm.HandleWorkConn("udp", workClient, &msg.StartWorkConn{ProxyName: "udp"})
serverRW, err := msg.NewUDPPacketReadWriter(workServer, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
writeDone := make(chan error, 1)
in := &msg.UDPPacket{
Content: []byte("binary udp"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
}
go func() {
writeDone <- serverRW.WriteMsg(in)
}()
frame, err := wire.NewConn(workServer).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
out, err := msg.DecodeUDPPacketBinary(frame.Payload[2:])
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
require.NoError(t, <-writeDone)
require.NoError(t, <-echoDone)
}
+65 -9
View File
@@ -20,7 +20,9 @@ import (
"io"
"net"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -61,15 +63,17 @@ func NewProxy(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) (pxy Proxy) {
var limiter *rate.Limiter
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
limiter = limit.NewBandwidthLimiter(limitBytes)
}
baseProxy := BaseProxy{
baseCfg: pxyConf.GetBaseConfig(),
configurer: pxyConf,
clientCfg: clientCfg,
encryptionKey: encryptionKey,
limiter: limiter,
@@ -77,6 +81,7 @@ func NewProxy(
vnetController: vnetController,
xl: xlog.FromContextSafe(ctx),
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
@@ -88,6 +93,7 @@ func NewProxy(
type BaseProxy struct {
baseCfg *v1.ProxyBaseConfig
configurer v1.ProxyConfigurer
clientCfg *v1.ClientCommonConfig
encryptionKey []byte
msgTransporter transport.MessageTransporter
@@ -98,15 +104,17 @@ type BaseProxy struct {
proxyPlugin plugin.Plugin
inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
udpPacketCodec string
}
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 {
@@ -117,6 +125,39 @@ 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
}
func (pxy *BaseProxy) Close() {
if pxy.proxyPlugin != nil {
pxy.proxyPlugin.Close()
@@ -171,6 +212,26 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
var srcAddr, dstAddr *net.TCPAddr
if m.SrcAddr != "" && m.SrcPort != 0 {
if m.DstAddr == "" {
m.DstAddr = "127.0.0.1"
}
var err error
srcAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
if err != nil {
xl.Warnf("resolve source address [%s] error: %v", m.SrcAddr, err)
_ = workConn.Close()
return
}
dstAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
if err != nil {
xl.Warnf("resolve destination address [%s] error: %v", m.DstAddr, err)
_ = workConn.Close()
return
}
}
remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
if err != nil {
xl.Errorf("wrap work connection: %v", err)
@@ -180,11 +241,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
// check if we need to send proxy protocol info
var connInfo plugin.ConnectionInfo
if m.SrcAddr != "" && m.SrcPort != 0 {
if m.DstAddr == "" {
m.DstAddr = "127.0.0.1"
}
srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
connInfo.SrcAddr = srcAddr
connInfo.DstAddr = dstAddr
}
+7 -4
View File
@@ -43,7 +43,8 @@ type Manager struct {
encryptionKey []byte
clientCfg *v1.ClientCommonConfig
ctx context.Context
ctx context.Context
udpPacketCodec string
}
func NewManager(
@@ -52,6 +53,7 @@ func NewManager(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Manager {
return &Manager{
proxies: make(map[string]*Wrapper),
@@ -61,6 +63,7 @@ func NewManager(
encryptionKey: encryptionKey,
clientCfg: clientCfg,
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
}
@@ -159,14 +162,14 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
}
}
if len(delPxyNames) > 0 {
xl.Infof("proxy removed: %s", delPxyNames)
xl.Infof("隧道移除: %s", delPxyNames)
}
addPxyNames := make([]string, 0)
for _, cfg := range proxyCfgs {
name := cfg.GetBaseConfig().Name
if _, ok := pm.proxies[name]; !ok {
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController, pm.udpPacketCodec)
if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
}
@@ -177,6 +180,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
}
}
if len(addPxyNames) > 0 {
xl.Infof("proxy added: %s", addPxyNames)
xl.Infof("添加隧道: %s", addPxyNames)
}
}
+47
View File
@@ -0,0 +1,47 @@
// 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 (
"io"
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/xlog"
)
func TestHandleTCPWorkConnectionRejectsInvalidAddress(t *testing.T) {
workConn, peerConn := net.Pipe()
defer peerConn.Close()
pxy := &BaseProxy{
baseCfg: &v1.ProxyBaseConfig{},
xl: xlog.New(),
}
pxy.HandleTCPWorkConnection(workConn, &msg.StartWorkConn{
SrcAddr: "[",
SrcPort: 1,
}, nil)
buffer := make([]byte, 1)
_, err := peerConn.Read(buffer)
require.ErrorIs(t, err, io.EOF)
}
+2 -1
View File
@@ -99,6 +99,7 @@ func NewWrapper(
eventHandler event.Handler,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Wrapper {
baseInfo := cfg.GetBaseConfig()
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
@@ -127,7 +128,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor")
}
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec)
return pw
}
+7 -1
View File
@@ -87,7 +87,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create SUDP packet read writer: %v", err)
_ = workConn.Close()
return
}
payloadConn := msg.NewConn(workConn, payloadRW)
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
+11 -4
View File
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
xl := pxy.xl
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
// close resources related with old workConn
pxy.Close()
@@ -97,10 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
pxy.mu.Lock()
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing.
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create UDP packet read writer: %v", err)
workConn.Close()
return
}
pxy.mu.Lock()
pxy.workConn = workConn
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
+11 -3
View File
@@ -33,6 +33,7 @@ import (
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/policy/security"
httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -263,7 +264,7 @@ func (svr *Service) Run(ctx context.Context) error {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
svr.stop()
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
}
go svr.keepControllerWorking()
@@ -308,7 +309,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
xl := xlog.FromContextSafe(svr.ctx)
loginFunc := func() (bool, error) {
xl.Infof("try to connect to server...")
xl.Infof("尝试连接到服务器...")
dialer := &controlSessionDialer{
ctx: svr.ctx,
common: svr.common,
@@ -319,7 +320,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
}
sessionCtx, err := dialer.Dial(svr.runID)
if err != nil {
xl.Warnf("connect to server error: %v", err)
xl.Warnf("连接服务器错误: %v", err)
if firstLoginExit {
svr.cancel(cancelErr{Err: err})
}
@@ -510,6 +511,13 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
proxies = config.CompleteProxyConfigurers(proxies)
visitors = config.CompleteVisitorConfigurers(visitors)
requirements := validation.GetClientConfigRequirements(reloadCommon, proxies, visitors)
if svr.vnetController == nil && requirements.VirtualNet {
return errors.New(
"VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup; " +
"restart frpc after configuring featureGates.VirtualNet and virtualNet.address",
)
}
// Atomically replace the entire configuration
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
+2 -2
View File
@@ -34,8 +34,8 @@ func newGracefulCloseTestService() *Service {
},
doneCh: make(chan struct{}),
}
ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil)
ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil)
ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil, "")
ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil, "")
return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})}
}
+7 -1
View File
@@ -113,7 +113,13 @@ func (sv *SUDPVisitor) dispatcher() {
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl := xlog.FromContextSafe(sv.ctx)
xl.Debugf("starting sudp proxy worker")
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, sv.clientCfg.Transport.WireProtocol, udpPacketCodecFromHelper(sv.helper))
if err != nil {
xl.Errorf("create SUDP packet read writer: %v", err)
_ = workConn.Close()
return
}
payloadConn := msg.NewConn(workConn, payloadRW)
wg := &sync.WaitGroup{}
wg.Add(2)
+11
View File
@@ -50,6 +50,17 @@ type Helper interface {
RunID() string
}
type udpPacketCodecProvider interface {
UDPPacketCodec() string
}
func udpPacketCodecFromHelper(helper Helper) string {
if provider, ok := helper.(udpPacketCodecProvider); ok {
return provider.UDPPacketCodec()
}
return ""
}
// Visitor is used for forward traffics from local port tot remote service.
type Visitor interface {
Run() error
+11
View File
@@ -53,7 +53,12 @@ func NewManager(
connectServer func() (*msg.Conn, error),
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodecs ...string,
) *Manager {
udpPacketCodec := ""
if len(udpPacketCodecs) > 0 {
udpPacketCodec = udpPacketCodecs[0]
}
m := &Manager{
clientCfg: clientCfg,
cfgs: make(map[string]v1.VisitorConfigurer),
@@ -68,6 +73,7 @@ func NewManager(
vnetController: vnetController,
transferConnFn: m.TransferConn,
runID: runID,
udpPacketCodec: udpPacketCodec,
}
return m
}
@@ -205,6 +211,7 @@ type visitorHelperImpl struct {
vnetController *vnet.Controller
transferConnFn func(name string, conn net.Conn) error
runID string
udpPacketCodec string
}
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
@@ -226,3 +233,7 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller {
func (v *visitorHelperImpl) RunID() string {
return v.runID
}
func (v *visitorHelperImpl) UDPPacketCodec() string {
return v.udpPacketCodec
}
+5 -1
View File
@@ -54,7 +54,11 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
Use: name,
Short: short,
Run: func(cmd *cobra.Command, args []string) {
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
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)
if err != nil {
fmt.Println(err)
os.Exit(1)
+13 -2
View File
@@ -48,8 +48,19 @@ 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
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
var cfg *v1.ClientCommonConfig
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], 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)
+5 -3
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,11 +141,13 @@ 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)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
}
+246 -15
View File
@@ -16,8 +16,11 @@ package sub
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -33,26 +36,29 @@ import (
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/featuregate"
"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 (
cfgFile string
cfgFiles []string
cfgDir string
showVersion bool
strictConfigMode bool
allowUnsafe []string
authTokens []string
bannerDisplayed bool
)
func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
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, ", ")))
}
@@ -68,15 +74,30 @@ 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(cfgFile, unsafeFeatures)
err := runClient(cfgFiles[0], unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -106,6 +127,29 @@ 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 {
@@ -131,12 +175,6 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
"please use yaml/json/toml format instead!\n")
}
if len(result.Common.FeatureGates) > 0 {
if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {
return err
}
}
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
}
@@ -186,7 +224,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(
@@ -194,12 +232,25 @@ 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("start frpc service for config file [%s] with aggregated configuration", cfgFile)
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
log.Infof("启动 frpc 服务 [%s]", cfgFile)
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
}
svr, err := client.NewService(client.ServiceOptions{
Common: cfg,
@@ -217,3 +268,183 @@ 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)
}
+15 -7
View File
@@ -29,22 +29,30 @@ func init() {
rootCmd.AddCommand(verifyCmd)
}
func verifyClientConfig(
configFile string,
strict bool,
unsafeFeatures *security.UnsafeFeatures,
) (validation.Warning, error) {
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(configFile, strict)
if err != nil {
return nil, err
}
return validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
}
var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify that the configures is valid",
RunE: func(cmd *cobra.Command, args []string) error {
if cfgFile == "" {
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
fmt.Println("frpc: the configuration file is not specified")
return nil
}
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
cfgFile := cfgFiles[0]
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
+67
View File
@@ -0,0 +1,67 @@
// 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 sub
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/policy/security"
)
func TestVerifyClientConfigFeatureGates(t *testing.T) {
tests := []struct {
name string
content string
wantErr string
}{
{
name: "VirtualNet enabled",
content: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.4/24"
`,
},
{
name: "VirtualNet disabled",
content: `featureGates = { VirtualNet = false }
virtualNet.address = "100.86.0.4/24"
`,
wantErr: "VirtualNet feature is not enabled",
},
{
name: "unknown feature gate",
content: `featureGates = { UnknownFeature = true }`,
wantErr: "unrecognized feature gate: UnknownFeature",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(t, os.WriteFile(configFile, []byte(tc.content), 0o600))
warning, err := verifyClientConfig(configFile, true, security.NewUnsafeFeatures(nil))
require.NoError(t, warning)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
+37
View File
@@ -275,6 +275,10 @@ 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"
@@ -336,6 +340,14 @@ 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"
@@ -348,6 +360,14 @@ 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"
@@ -361,6 +381,15 @@ 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"
@@ -380,6 +409,14 @@ 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"
+12
View File
@@ -40,6 +40,12 @@ 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
@@ -52,6 +58,12 @@ 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
+19 -1
View File
@@ -4,6 +4,8 @@ go 1.25.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/google/uuid v1.6.0
@@ -13,7 +15,7 @@ require (
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.36.3
github.com/pelletier/go-toml/v2 v2.2.0
github.com/pires/go-proxyproto v0.7.0
github.com/pires/go-proxyproto v0.15.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.60.0
github.com/rodaine/table v1.2.0
@@ -40,10 +42,16 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.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
@@ -52,18 +60,25 @@ 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.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
@@ -76,3 +91,6 @@ require (
// 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
+35 -6
View File
@@ -2,13 +2,29 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
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/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=
@@ -20,12 +36,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/golib v0.8.1 h1:pHcIu0zAcZ6VTkO1dW/meelCGN5nem52DKCBY7cUvyA=
github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
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/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=
@@ -70,16 +86,23 @@ 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/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
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/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=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -99,8 +122,9 @@ 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=
@@ -140,6 +164,8 @@ 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=
@@ -154,6 +180,8 @@ golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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=
@@ -179,6 +207,7 @@ 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=
+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='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
arch_all='amd64 arm arm64'
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 instead of using system default one")
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
}
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
+2 -1
View File
@@ -49,7 +49,8 @@ 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.
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
// supported, e.g. "https://1.1.1.1/dns-query".
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
+8
View File
@@ -369,6 +369,12 @@ 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) {
@@ -376,6 +382,7 @@ 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) {
@@ -383,6 +390,7 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.CustomDomains = m.CustomDomains
c.SubDomain = m.SubDomain
c.HTTPRedirect = m.HTTPRedirect
}
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
+71 -24
View File
@@ -16,6 +16,7 @@ package v1
import (
"reflect"
"slices"
"github.com/samber/lo"
@@ -24,29 +25,31 @@ import (
)
const (
PluginHTTP2HTTPS = "http2https"
PluginHTTPProxy = "http_proxy"
PluginHTTPS2HTTP = "https2http"
PluginHTTPS2HTTPS = "https2https"
PluginHTTP2HTTP = "http2http"
PluginSocks5 = "socks5"
PluginStaticFile = "static_file"
PluginUnixDomainSocket = "unix_domain_socket"
PluginTLS2Raw = "tls2raw"
PluginVirtualNet = "virtual_net"
PluginHTTP2HTTPS = "http2https"
PluginHTTP2HTTPSRedirect = "http2https_redirect"
PluginHTTPProxy = "http_proxy"
PluginHTTPS2HTTP = "https2http"
PluginHTTPS2HTTPS = "https2https"
PluginHTTP2HTTP = "http2http"
PluginSocks5 = "socks5"
PluginStaticFile = "static_file"
PluginUnixDomainSocket = "unix_domain_socket"
PluginTLS2Raw = "tls2raw"
PluginVirtualNet = "virtual_net"
)
var clientPluginOptionsTypeMap = map[string]reflect.Type{
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
}
type ClientPluginOptions interface {
@@ -80,6 +83,29 @@ 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"`
@@ -98,6 +124,21 @@ 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"`
@@ -122,6 +163,7 @@ 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() {
@@ -135,6 +177,7 @@ 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
}
@@ -146,6 +189,7 @@ 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() {
@@ -159,6 +203,7 @@ 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
}
@@ -230,10 +275,11 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
}
type TLS2RawPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
}
func (o *TLS2RawPluginOptions) Complete() {}
@@ -243,6 +289,7 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
return nil
}
out := *o
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
+14 -1
View File
@@ -51,6 +51,12 @@ 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
@@ -118,6 +124,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.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
@@ -167,12 +174,18 @@ type ServerTransportConfig struct {
// If negative, keep-alive probes are disabled.
TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"`
// MaxPoolCount specifies the maximum pool size for each proxy. By default,
// this value is 5.
// this value is 5. Negative values are invalid.
MaxPoolCount int64 `json:"maxPoolCount,omitempty"`
// HeartBeatTimeout specifies the maximum time to wait for a heartbeat
// 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.
+38 -1
View File
@@ -51,14 +51,51 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
}
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
gates := featuregate.NewFeatureGate()
if err := gates.SetFromMap(c.FeatureGates); err != nil {
return nil, err
}
if c.VirtualNet.Address != "" {
if !featuregate.Enabled(featuregate.VirtualNet) {
if !gates.Enabled(featuregate.VirtualNet) {
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
}
return nil, nil
}
// ClientConfigRequirements describes runtime capabilities needed by a client configuration.
type ClientConfigRequirements struct {
VirtualNet bool
}
// GetClientConfigRequirements returns the runtime capabilities needed by a client configuration.
func GetClientConfigRequirements(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) ClientConfigRequirements {
requirements := ClientConfigRequirements{}
if common != nil && common.VirtualNet.Address != "" {
requirements.VirtualNet = true
}
for _, cfg := range proxyCfgs {
if cfg.GetBaseConfig().Plugin.Type == v1.PluginVirtualNet {
requirements.VirtualNet = true
break
}
}
if !requirements.VirtualNet {
for _, cfg := range visitorCfgs {
if cfg.GetBaseConfig().Plugin.Type == v1.VisitorPluginVirtualNet {
requirements.VirtualNet = true
break
}
}
}
return requirements
}
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
var errs error
if !slices.Contains(SupportedAuthMethods, c.Method) {
+140
View File
@@ -0,0 +1,140 @@
// 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"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
)
func validateClientFeatureGates(t *testing.T, gates map[string]bool, virtualNetAddress string) error {
t.Helper()
cfg := &v1.ClientCommonConfig{
FeatureGates: gates,
VirtualNet: v1.VirtualNetConfig{
Address: virtualNetAddress,
},
}
require.NoError(t, cfg.Complete())
_, err := NewConfigValidator(security.NewUnsafeFeatures(nil)).ValidateClientCommonConfig(cfg)
return err
}
func TestValidateClientFeatureGates(t *testing.T) {
tests := []struct {
name string
featureGates map[string]bool
virtualNetAddress string
wantErr string
}{
{
name: "VirtualNet enabled",
featureGates: map[string]bool{"VirtualNet": true},
virtualNetAddress: "100.86.0.4/24",
},
{
name: "VirtualNet explicitly disabled",
featureGates: map[string]bool{"VirtualNet": false},
virtualNetAddress: "100.86.0.4/24",
wantErr: "VirtualNet feature is not enabled",
},
{
name: "VirtualNet disabled by default",
virtualNetAddress: "100.86.0.4/24",
wantErr: "VirtualNet feature is not enabled",
},
{
name: "unknown feature gate",
featureGates: map[string]bool{"UnknownFeature": true},
wantErr: "unrecognized feature gate: UnknownFeature",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateClientFeatureGates(t, tc.featureGates, tc.virtualNetAddress)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestGetClientConfigRequirements(t *testing.T) {
virtualNetProxy := &v1.STCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{Type: v1.PluginVirtualNet},
},
},
}
virtualNetVisitor := &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Plugin: v1.TypedVisitorPluginOptions{Type: v1.VisitorPluginVirtualNet},
},
}
tests := []struct {
name string
common *v1.ClientCommonConfig
proxies []v1.ProxyConfigurer
visitors []v1.VisitorConfigurer
wantVNet bool
}{
{name: "no requirements"},
{
name: "common VirtualNet address",
common: &v1.ClientCommonConfig{VirtualNet: v1.VirtualNetConfig{Address: "100.86.0.4/24"}},
wantVNet: true,
},
{name: "VirtualNet proxy", proxies: []v1.ProxyConfigurer{virtualNetProxy}, wantVNet: true},
{name: "VirtualNet visitor", visitors: []v1.VisitorConfigurer{virtualNetVisitor}, wantVNet: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := GetClientConfigRequirements(tc.common, tc.proxies, tc.visitors)
require.Equal(t, tc.wantVNet, got.VirtualNet)
})
}
}
func TestValidateClientFeatureGatesAreConfigScoped(t *testing.T) {
defaultGatesBefore := featuregate.DefaultFeatureGates.String()
require.NoError(t, validateClientFeatureGates(
t,
map[string]bool{"VirtualNet": true},
"100.86.0.4/24",
))
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
err := validateClientFeatureGates(
t,
map[string]bool{"VirtualNet": false},
"100.86.0.4/24",
)
require.ErrorContains(t, err, "VirtualNet feature is not enabled")
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
}
+38
View File
@@ -16,6 +16,8 @@ package validation
import (
"errors"
"fmt"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -24,6 +26,8 @@ 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:
@@ -45,10 +49,17 @@ 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
}
@@ -56,6 +67,9 @@ 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
}
@@ -77,5 +91,29 @@ 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
}
+4 -2
View File
@@ -79,9 +79,11 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
}
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
subDomainHost := strings.ToLower(s.SubDomainHost)
for _, domain := range c.CustomDomains {
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
canonicalDomain := strings.ToLower(domain)
if subDomainHost != "" && len(strings.Split(subDomainHost, ".")) < len(strings.Split(canonicalDomain, ".")) {
if strings.HasSuffix(canonicalDomain, "."+subDomainHost) {
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
}
}
+76
View File
@@ -0,0 +1,76 @@
// 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 TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *testing.T) {
tests := []struct {
name string
subDomainHost string
customDomain string
wantErr bool
}{
{
name: "lowercase subdomain",
subDomainHost: "frp.example.com",
customDomain: "victim.frp.example.com",
wantErr: true,
},
{
name: "mixed case custom domain",
subDomainHost: "frp.example.com",
customDomain: "victim.FRP.example.com",
wantErr: true,
},
{
name: "mixed case wildcard domain",
subDomainHost: "frp.example.com",
customDomain: "*.FRP.example.com",
wantErr: true,
},
{
name: "mixed case subdomain host",
subDomainHost: "FRP.Example.Com",
customDomain: "victim.frp.example.com",
wantErr: true,
},
{
name: "external domain",
subDomainHost: "frp.example.com",
customDomain: "victim.example.net",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDomainConfigForServer(
&v1.DomainConfig{CustomDomains: []string{tt.customDomain}},
&v1.ServerConfig{SubDomainHost: tt.subDomainHost},
)
if tt.wantErr {
require.ErrorContains(t, err, "should not belong to subdomain host")
return
}
require.NoError(t, err)
})
}
}
+3
View File
@@ -51,6 +51,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort"))
errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort"))
errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort"))
if c.Transport.MaxPoolCount < 0 {
errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative"))
}
for _, p := range c.HTTPPlugins {
if !lo.Every(SupportedHTTPPluginOps, p.Ops) {
+51
View File
@@ -0,0 +1,51 @@
// 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 (
"math"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateServerConfigMaxPoolCount(t *testing.T) {
for _, tc := range []struct {
name string
maxPoolCount int64
wantErr bool
}{
{name: "negative", maxPoolCount: -1, wantErr: true},
{name: "zero", maxPoolCount: 0},
{name: "positive", maxPoolCount: 5},
{name: "maximum int64", maxPoolCount: math.MaxInt64},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := validServerConfigWithAuth(v1.AuthServerConfig{Method: v1.AuthMethodToken})
cfg.Transport.MaxPoolCount = tc.maxPoolCount
require.NoError(t, cfg.Complete())
_, err := NewConfigValidator(nil).ValidateServerConfig(cfg)
if tc.wantErr {
require.ErrorContains(t, err, "invalid transport.maxPoolCount")
require.ErrorContains(t, err, "must be non-negative")
return
}
require.NoError(t, err)
})
}
}
+9 -3
View File
@@ -253,8 +253,9 @@ 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 proxyStats.ProxyType != proxyType {
if !filterAll && proxyStats.ProxyType != proxyType {
continue
}
res = append(res, toProxyStats(name, proxyStats))
@@ -267,8 +268,13 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok && proxyStats.ProxyType == proxyType {
res = toProxyStats(proxyName, proxyStats)
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 {
res = toProxyStats(proxyName, proxyStats)
}
}
return
}
+1
View File
@@ -124,6 +124,7 @@ 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"`
+199
View File
@@ -0,0 +1,199 @@
// 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"
"fmt"
"net"
"runtime"
"testing"
"github.com/fatedier/frp/pkg/proto/wire"
)
type udpBenchmarkCase struct {
name string
packet *UDPPacket
}
var (
udpBenchmarkBytesSink []byte
udpBenchmarkMessageSink Message
)
func udpBenchmarkCases(payloadSize int) []udpBenchmarkCase {
content := bytes.Repeat([]byte{0x5a}, payloadSize)
return []udpBenchmarkCase{
{
name: "ipv4-remote",
packet: &UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
},
},
{
name: "ipv4-local-remote",
packet: &UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 23456},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
},
},
{
name: "ipv6-remote",
packet: &UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
},
},
{
name: "ipv6-local-remote",
packet: &UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0"},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1"},
},
},
}
}
func TestUDPPacketV2FrameSizes(t *testing.T) {
t.Logf("environment go=%s goos=%s goarch=%s gomaxprocs=%d", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
jsonFrame := udpBenchmarkWireBytes(t, tc.packet, "")
binaryFrame := udpBenchmarkWireBytes(t, tc.packet, wire.UDPPacketCodecBinary)
saving := 100 * float64(len(jsonFrame)-len(binaryFrame)) / float64(len(jsonFrame))
t.Logf("frame payload=%d case=%s json_bytes=%d binary_bytes=%d binary_saving_pct=%.2f", payloadSize, tc.name, len(jsonFrame), len(binaryFrame), saving)
}
}
}
func udpBenchmarkWireBytes(t testing.TB, packet *UDPPacket, codec string) []byte {
t.Helper()
var buf bytes.Buffer
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec)
if err != nil {
t.Fatalf("create UDP read writer: %v", err)
}
if err := rw.WriteMsg(packet); err != nil {
t.Fatalf("write UDP packet: %v", err)
}
return append([]byte(nil), buf.Bytes()...)
}
type udpBenchmarkReadWriter struct {
reader bytes.Reader
}
func (rw *udpBenchmarkReadWriter) Read(p []byte) (int, error) {
return rw.reader.Read(p)
}
func (rw *udpBenchmarkReadWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (rw *udpBenchmarkReadWriter) Reset(p []byte) {
rw.reader.Reset(p)
}
func udpBenchmarkValidatePacket(b testing.TB, got, want *UDPPacket) {
b.Helper()
if !bytes.Equal(got.Content, want.Content) || !udpBenchmarkUDPAddrEqual(got.LocalAddr, want.LocalAddr) ||
!udpBenchmarkUDPAddrEqual(got.RemoteAddr, want.RemoteAddr) {
b.Fatalf("decoded packet mismatch: got %+v, want %+v", got, want)
}
}
func udpBenchmarkUDPAddrEqual(got, want *net.UDPAddr) bool {
if got == nil || want == nil {
return got == want
}
return got.IP.Equal(want.IP) && got.Port == want.Port && got.Zone == want.Zone
}
func BenchmarkUDPPacketV2CodecWrite(b *testing.B) {
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
var buf bytes.Buffer
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
expected := udpBenchmarkWireBytes(b, tc.packet, codec.value)
b.SetBytes(int64(len(expected)))
for b.Loop() {
buf.Reset()
if err := rw.WriteMsg(tc.packet); err != nil {
b.Fatal(err)
}
}
if !bytes.Equal(buf.Bytes(), expected) {
b.Fatalf("encoded packet mismatch: got %d bytes, want %d", buf.Len(), len(expected))
}
udpBenchmarkBytesSink = buf.Bytes()
})
}
}
}
}
func BenchmarkUDPPacketV2CodecRead(b *testing.B) {
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
encoded := udpBenchmarkWireBytes(b, tc.packet, codec.value)
stream := &udpBenchmarkReadWriter{}
rw, err := NewUDPPacketReadWriter(stream, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
var decoded Message
b.SetBytes(int64(len(encoded)))
for b.Loop() {
stream.Reset(encoded)
decoded, err = rw.ReadMsg()
if err != nil {
b.Fatal(err)
}
}
packet, ok := decoded.(*UDPPacket)
if !ok {
b.Fatalf("decoded message type %T, want *UDPPacket", decoded)
}
udpBenchmarkValidatePacket(b, packet, tc.packet)
udpBenchmarkMessageSink = decoded
})
}
}
}
}
+338
View File
@@ -0,0 +1,338 @@
// 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 (
"encoding/binary"
"fmt"
"io"
"net"
"unicode/utf8"
"github.com/fatedier/frp/pkg/proto/wire"
)
const MaxUDPPayloadSize = 65507
const (
udpPacketFlagLocalAddr byte = 1 << 0
udpPacketFlagRemoteAddr byte = 1 << 1
udpPacketValidFlags = udpPacketFlagLocalAddr | udpPacketFlagRemoteAddr
)
type binaryUDPAddr struct {
family byte
ip []byte
port uint16
zone string
}
// EncodeUDPPacketBinary encodes the body of a V2 binary UDP packet message.
// RemoteAddr is required by the UDP forwarding path.
func EncodeUDPPacketBinary(packet *UDPPacket) ([]byte, error) {
if packet == nil {
return nil, fmt.Errorf("nil UDP packet")
}
if packet.RemoteAddr == nil {
return nil, fmt.Errorf("UDP packet missing remote address")
}
if len(packet.Content) > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", len(packet.Content), MaxUDPPayloadSize)
}
var flags byte
var localAddr, remoteAddr binaryUDPAddr
bodyLen := 1 + 2 + len(packet.Content)
if packet.LocalAddr != nil {
flags |= udpPacketFlagLocalAddr
var err error
localAddr, err = validateBinaryUDPAddr(packet.LocalAddr)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
bodyLen += binaryUDPAddrLen(localAddr)
}
flags |= udpPacketFlagRemoteAddr
var err error
remoteAddr, err = validateBinaryUDPAddr(packet.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
bodyLen += binaryUDPAddrLen(remoteAddr)
if 2+bodyLen > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+bodyLen, wire.DefaultMaxFramePayloadSize)
}
body := make([]byte, bodyLen)
body[0] = flags
offset := 1
if flags&udpPacketFlagLocalAddr != 0 {
offset = putBinaryUDPAddr(body, offset, localAddr)
}
offset = putBinaryUDPAddr(body, offset, remoteAddr)
binary.BigEndian.PutUint16(body[offset:offset+2], uint16(len(packet.Content)))
offset += 2
copy(body[offset:], packet.Content)
return body, nil
}
// DecodeUDPPacketBinary decodes a V2 binary UDP packet body and returns data
// that does not alias the input frame buffer.
func DecodeUDPPacketBinary(body []byte) (*UDPPacket, error) {
if len(body) < 3 {
return nil, fmt.Errorf("UDP packet body too short: %d", len(body))
}
if 2+len(body) > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+len(body), wire.DefaultMaxFramePayloadSize)
}
flags := body[0]
if flags&^udpPacketValidFlags != 0 {
return nil, fmt.Errorf("reserved UDP packet flags set: 0x%02x", flags)
}
if flags&udpPacketFlagRemoteAddr == 0 {
return nil, fmt.Errorf("UDP packet missing remote address")
}
packet := &UDPPacket{}
offset := 1
var err error
if flags&udpPacketFlagLocalAddr != 0 {
packet.LocalAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
}
if flags&udpPacketFlagRemoteAddr != 0 {
packet.RemoteAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
}
if len(body)-offset < 2 {
return nil, fmt.Errorf("truncated UDP payload length")
}
payloadLen := int(binary.BigEndian.Uint16(body[offset : offset+2]))
offset += 2
if payloadLen > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", payloadLen, MaxUDPPayloadSize)
}
remaining := len(body) - offset
if remaining < payloadLen {
return nil, fmt.Errorf("truncated UDP payload: have %d want %d", remaining, payloadLen)
}
if remaining > payloadLen {
return nil, fmt.Errorf("trailing UDP packet bytes: %d", remaining-payloadLen)
}
packet.Content = append([]byte(nil), body[offset:offset+payloadLen]...)
return packet, nil
}
func validateBinaryUDPAddr(addr *net.UDPAddr) (binaryUDPAddr, error) {
if addr.Port < 0 || addr.Port > 65535 {
return binaryUDPAddr{}, fmt.Errorf("port out of range: %d", addr.Port)
}
if ip := addr.IP.To4(); ip != nil {
if addr.Zone != "" {
return binaryUDPAddr{}, fmt.Errorf("IPv4 zone is forbidden")
}
return binaryUDPAddr{family: 4, ip: ip, port: uint16(addr.Port)}, nil
}
ip := addr.IP.To16()
if ip == nil {
return binaryUDPAddr{}, fmt.Errorf("invalid IP")
}
if len(addr.Zone) > 255 {
return binaryUDPAddr{}, fmt.Errorf("zone exceeds 255 bytes")
}
if !utf8.ValidString(addr.Zone) {
return binaryUDPAddr{}, fmt.Errorf("zone is not valid UTF-8")
}
return binaryUDPAddr{family: 6, ip: ip, port: uint16(addr.Port), zone: addr.Zone}, nil
}
func binaryUDPAddrLen(addr binaryUDPAddr) int {
return 1 + len(addr.ip) + 2 + 1 + len(addr.zone)
}
func putBinaryUDPAddr(body []byte, offset int, addr binaryUDPAddr) int {
body[offset] = addr.family
offset++
copy(body[offset:], addr.ip)
offset += len(addr.ip)
binary.BigEndian.PutUint16(body[offset:offset+2], addr.port)
offset += 2
body[offset] = byte(len(addr.zone))
offset++
copy(body[offset:], addr.zone)
return offset + len(addr.zone)
}
func readBinaryUDPAddr(body []byte, offset int) (*net.UDPAddr, int, error) {
if offset >= len(body) {
return nil, offset, fmt.Errorf("truncated address family")
}
family := body[offset]
offset++
var ipLen int
switch family {
case 4:
ipLen = net.IPv4len
case 6:
ipLen = net.IPv6len
default:
return nil, offset, fmt.Errorf("unknown address family %d", family)
}
if len(body)-offset < ipLen+3 {
return nil, offset, fmt.Errorf("truncated address")
}
ip := append(net.IP(nil), body[offset:offset+ipLen]...)
offset += ipLen
port := binary.BigEndian.Uint16(body[offset : offset+2])
offset += 2
zoneLen := int(body[offset])
offset++
if len(body)-offset < zoneLen {
return nil, offset, fmt.Errorf("truncated zone")
}
zoneBytes := body[offset : offset+zoneLen]
if family == 4 && zoneLen != 0 {
return nil, offset, fmt.Errorf("IPv4 zone is forbidden")
}
if !utf8.Valid(zoneBytes) {
return nil, offset, fmt.Errorf("zone is not valid UTF-8")
}
offset += zoneLen
return &net.UDPAddr{IP: ip, Port: int(port), Zone: string(zoneBytes)}, offset, nil
}
type V2BinaryUDPPacketReadWriter struct {
conn *wire.Conn
}
func NewV2BinaryUDPPacketReadWriter(rw io.ReadWriter) *V2BinaryUDPPacketReadWriter {
return &V2BinaryUDPPacketReadWriter{conn: wire.NewConn(rw)}
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsg() (Message, error) {
frame, err := rw.conn.ReadFrame()
if err != nil {
return nil, err
}
if isV2MessageType(frame, V2TypeUDPPacketBinary) {
return decodeV2BinaryUDPPacketFrame(frame)
}
if isV2MessageType(frame, V2TypeUDPPacket) {
return nil, fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return DecodeV2MessageFrame(frame)
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsgInto(out Message) error {
frame, err := rw.conn.ReadFrame()
if err != nil {
return err
}
if packetOut, ok := out.(*UDPPacket); ok {
if !isV2MessageType(frame, V2TypeUDPPacketBinary) {
return unexpectedV2UDPPacketType(frame)
}
packet, err := decodeV2BinaryUDPPacketFrame(frame)
if err != nil {
return err
}
*packetOut = *packet
return nil
}
return DecodeV2MessageFrameInto(frame, out)
}
func (rw *V2BinaryUDPPacketReadWriter) WriteMsg(message Message) error {
var packet *UDPPacket
switch typed := message.(type) {
case *UDPPacket:
packet = typed
case UDPPacket:
packet = &typed
default:
frame, err := EncodeV2MessageFrame(message)
if err != nil {
return err
}
return rw.conn.WriteFrame(frame)
}
body, err := EncodeUDPPacketBinary(packet)
if err != nil {
return err
}
payload := make([]byte, 2+len(body))
binary.BigEndian.PutUint16(payload[:2], V2TypeUDPPacketBinary)
copy(payload[2:], body)
return rw.conn.WriteFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
}
func decodeV2BinaryUDPPacketFrame(frame *wire.Frame) (*UDPPacket, error) {
if frame.Type != wire.FrameTypeMessage {
return nil, fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return nil, fmt.Errorf("message frame payload too short")
}
if binary.BigEndian.Uint16(frame.Payload[:2]) != V2TypeUDPPacketBinary {
return nil, unexpectedV2UDPPacketType(frame)
}
return DecodeUDPPacketBinary(frame.Payload[2:])
}
func isV2MessageType(frame *wire.Frame, typeID uint16) bool {
return frame.Type == wire.FrameTypeMessage && len(frame.Payload) >= 2 && binary.BigEndian.Uint16(frame.Payload[:2]) == typeID
}
func unexpectedV2UDPPacketType(frame *wire.Frame) error {
if frame.Type != wire.FrameTypeMessage {
return fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return fmt.Errorf("message frame payload too short")
}
typeID := binary.BigEndian.Uint16(frame.Payload[:2])
if typeID == V2TypeUDPPacket {
return fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return fmt.Errorf("unexpected message type %d, want %d", typeID, V2TypeUDPPacketBinary)
}
// NewUDPPacketReadWriter selects the negotiated packet codec without changing
// the framing or codecs used by non-UDP messages on the work connection.
func NewUDPPacketReadWriter(rw io.ReadWriter, wireProtocol, udpPacketCodec string) (ReadWriter, error) {
switch wireProtocol {
case "", wire.ProtocolV1:
if udpPacketCodec != "" {
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
}
return NewV1ReadWriter(rw), nil
case wire.ProtocolV2:
switch udpPacketCodec {
case "":
return NewV2ReadWriter(rw), nil
case wire.UDPPacketCodecBinary:
return NewV2BinaryUDPPacketReadWriter(rw), nil
default:
return nil, fmt.Errorf("unsupported UDP packet codec %q", udpPacketCodec)
}
default:
return nil, fmt.Errorf("unsupported wire protocol %q", wireProtocol)
}
}
+248
View File
@@ -0,0 +1,248 @@
// 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
package msg
import (
"bytes"
"encoding/binary"
"net"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestUDPPacketBinaryRoundTrip(t *testing.T) {
payload := bytes.Repeat([]byte{0xa5}, 1472)
in := &UDPPacket{
Content: payload,
LocalAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::1"),
Port: 1234,
Zone: "en0",
},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.LocalAddr.String(), out.LocalAddr.String())
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
body[len(body)-1] ^= 0xff
body[25] ^= 0xff
require.Equal(t, byte(0xa5), out.Content[len(out.Content)-1], "decoded payload must own frame bytes")
require.Equal(t, byte(203), out.RemoteAddr.IP.To4()[0], "decoded address must own frame bytes")
}
func TestUDPPacketBinarySizesAndOptionalLocalAddress(t *testing.T) {
for _, size := range []int{0, 32, 128, 512, 1200, 1472, 4096, 49107, 65507} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
in := &UDPPacket{
Content: bytes.Repeat([]byte{byte(size)}, size),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, len(in.Content), len(out.Content))
if size == 0 {
require.Empty(t, out.Content)
} else {
require.Equal(t, in.Content, out.Content)
}
})
}
}
func TestUDPPacketBinaryMalformed(t *testing.T) {
valid, err := EncodeUDPPacketBinary(&UDPPacket{
Content: []byte("payload"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.NoError(t, err)
tests := [][]byte{
{0x80, 0, 0},
{0x02, 4, 1, 2},
{0x02, 4, 1, 2, 3, 4, 0xd4},
append(append([]byte(nil), valid...), 0),
}
for _, malformed := range tests {
_, err := DecodeUDPPacketBinary(malformed)
require.Error(t, err)
}
_, err = DecodeUDPPacketBinary([]byte{0, 0, 0})
require.ErrorContains(t, err, "missing remote address")
payloadLengthOffset := len(valid) - len("payload") - 2
invalidPayloadLength := append([]byte(nil), valid...)
binary.BigEndian.PutUint16(invalidPayloadLength[payloadLengthOffset:payloadLengthOffset+2], 0xffff)
_, err = DecodeUDPPacketBinary(invalidPayloadLength)
require.ErrorContains(t, err, "payload length")
truncatedPayload := append([]byte(nil), valid[:payloadLengthOffset+2]...)
binary.BigEndian.PutUint16(truncatedPayload[payloadLengthOffset:payloadLengthOffset+2], 1)
_, err = DecodeUDPPacketBinary(truncatedPayload)
require.ErrorContains(t, err, "truncated UDP payload")
_, err = DecodeUDPPacketBinary(make([]byte, wire.DefaultMaxFramePayloadSize))
require.ErrorContains(t, err, "frame payload length")
badIPv4Zone := []byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 1, 'z', 0, 0}
_, err = DecodeUDPPacketBinary(badIPv4Zone)
require.ErrorContains(t, err, "IPv4 zone")
badFamily := []byte{2, 9, 0, 0}
_, err = DecodeUDPPacketBinary(badFamily)
require.ErrorContains(t, err, "unknown address family")
badUTF8 := make([]byte, 0, 24)
badUTF8 = append(badUTF8, 2, 6)
badUTF8 = append(badUTF8, make([]byte, 16)...)
badUTF8 = append(badUTF8, 0, 1, 1, 0xff, 0, 0)
_, err = DecodeUDPPacketBinary(badUTF8)
require.ErrorContains(t, err, "UTF-8")
}
func TestUDPPacketBinaryEncodeRejectsInvalidPackets(t *testing.T) {
_, err := EncodeUDPPacketBinary(&UDPPacket{})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234},
})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize+1),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.ErrorContains(t, err, "exceeds limit")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 1, Zone: "bad"}})
require.ErrorContains(t, err, "IPv4 zone")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string(bytes.Repeat([]byte{'z'}, 256))}})
require.ErrorContains(t, err, "zone exceeds")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string([]byte{0xff})}})
require.ErrorContains(t, err, "UTF-8")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: -1}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: 65536}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.IP{1, 2, 3}}})
require.ErrorContains(t, err, "invalid IP")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Zone: string(bytes.Repeat([]byte{'z'}, 255))},
})
require.ErrorContains(t, err, "frame payload length")
}
func TestV2BinaryUDPPacketReadWriterPreservesOtherMessages(t *testing.T) {
var buf bytes.Buffer
rw := NewV2BinaryUDPPacketReadWriter(&buf)
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
require.NoError(t, rw.WriteMsg(in))
require.NoError(t, rw.WriteMsg(&Ping{Timestamp: 7}))
frameConn := wire.NewConn(&buf)
frame, err := frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
frame, err = frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypePing, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2BinaryUDPPacketReadWriterRoundTripAndCodecInvariant(t *testing.T) {
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
var binaryStream bytes.Buffer
binaryWriter, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.NoError(t, binaryWriter.WriteMsg(in))
binaryReader, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
out, err := binaryReader.ReadMsg()
require.NoError(t, err)
require.Equal(t, in.Content, out.(*UDPPacket).Content)
for _, read := range []func(ReadWriter) error{
func(rw ReadWriter) error {
_, err := rw.ReadMsg()
return err
},
func(rw ReadWriter) error {
return rw.ReadMsgInto(&UDPPacket{})
},
} {
var jsonStream bytes.Buffer
require.NoError(t, NewReadWriter(&jsonStream, wire.ProtocolV2).WriteMsg(in))
negotiatedReader, err := NewUDPPacketReadWriter(&jsonStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.ErrorContains(t, read(negotiatedReader), "JSON UDP packet after binary codec negotiation")
}
var fallbackStream bytes.Buffer
fallbackWriter, err := NewUDPPacketReadWriter(&fallbackStream, wire.ProtocolV2, "")
require.NoError(t, err)
require.NoError(t, fallbackWriter.WriteMsg(in))
frame, err := wire.NewConn(&fallbackStream).ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestNewUDPPacketReadWriterDefaultProtocolUsesV1(t *testing.T) {
var stream bytes.Buffer
rw, err := NewUDPPacketReadWriter(&stream, "", "")
require.NoError(t, err)
require.IsType(t, &V1ReadWriter{}, rw)
require.NoError(t, rw.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.Equal(t, TypeUDPPacket, stream.Bytes()[0])
}
func TestNewUDPPacketReadWriterRejectsInvalidSelection(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
udpPacketCodec string
errorSubstring string
}{
{
name: "binary codec over v1",
wireProtocol: wire.ProtocolV1,
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "binary codec over default protocol",
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "unknown v2 codec",
wireProtocol: wire.ProtocolV2,
udpPacketCodec: "unknown",
errorSubstring: "unsupported UDP packet codec",
},
{
name: "unknown wire protocol",
wireProtocol: "unknown",
errorSubstring: "unsupported wire protocol",
},
} {
t.Run(tc.name, func(t *testing.T) {
rw, err := NewUDPPacketReadWriter(&bytes.Buffer{}, tc.wireProtocol, tc.udpPacketCodec)
require.Nil(t, rw)
require.ErrorContains(t, err, tc.errorSubstring)
})
}
}
func FuzzDecodeUDPPacketBinary(f *testing.F) {
f.Add([]byte{0, 0, 0})
f.Add([]byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 0, 0, 1})
f.Fuzz(func(t *testing.T, body []byte) {
_, _ = DecodeUDPPacketBinary(body)
})
}
+1
View File
@@ -43,6 +43,7 @@ const (
V2TypeNatHoleResp uint16 = 16
V2TypeNatHoleSid uint16 = 17
V2TypeNatHoleReport uint16 = 18
V2TypeUDPPacketBinary uint16 = 19
)
var v2MsgTypeMap = map[uint16]any{
+3
View File
@@ -84,6 +84,9 @@ func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(16), V2TypeNatHoleResp)
require.Equal(t, uint16(17), V2TypeNatHoleSid)
require.Equal(t, uint16(18), V2TypeNatHoleReport)
require.Equal(t, uint16(19), V2TypeUDPPacketBinary)
_, registered := v2MsgTypeMap[V2TypeUDPPacketBinary]
require.False(t, registered, "binary UDP has a dedicated codec and must not alter generic type registry")
}
func TestV2MessageFrameEncoding(t *testing.T) {
+3 -5
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", err)
log.Debugf("sid [%s] analysis error: %v", sid, err)
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
}
@@ -385,7 +385,6 @@ 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
@@ -394,9 +393,8 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if err != nil {
return nil
}
ports = append(ports, msg.PortsRange{
return []msg.PortsRange{{
From: max(port-difference-5, port-maxNumber, 1),
To: min(port+difference+5, port+maxNumber, 65535),
})
return ports
}}
}
+212
View File
@@ -0,0 +1,212 @@
// 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-01caDirURL=%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
@@ -0,0 +1,111 @@
// 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()
}
+19 -2
View File
@@ -18,6 +18,7 @@ package client
import (
"context"
"crypto/tls"
stdlog "log"
"net/http"
"net/http/httputil"
@@ -80,11 +81,27 @@ 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,
@@ -93,7 +110,7 @@ func newHTTPSBridgePluginServer(
go func() {
_ = p.s.ServeTLS(listener, "", "")
}()
return p, nil
return p
}
func newHTTPBridgeReverseProxy(
+11 -1
View File
@@ -17,6 +17,7 @@
package client
import (
"fmt"
"net/http/httputil"
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -32,7 +33,7 @@ type HTTPS2HTTPPlugin struct {
*httpBridgePlugin
}
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPPluginOptions)
p := &HTTPS2HTTPPlugin{
@@ -49,6 +50,15 @@ func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi
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
+11 -1
View File
@@ -18,6 +18,7 @@ package client
import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httputil"
@@ -34,7 +35,7 @@ type HTTPS2HTTPSPlugin struct {
*httpBridgePlugin
}
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
p := &HTTPS2HTTPSPlugin{
@@ -55,6 +56,15 @@ func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plug
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
@@ -33,7 +33,12 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
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{
Handler: withMisdirectedRequestCheck(handler),
ReadHeaderTimeout: 60 * time.Second,
@@ -42,7 +47,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
if !lo.FromPtr(enableHTTP2) {
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
}
return server, nil
return server
}
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
+1
View File
@@ -30,6 +30,7 @@ import (
type PluginContext struct {
Name string
HostAllowList []string
VnetController *vnet.Controller
}
+13 -4
View File
@@ -39,16 +39,25 @@ type TLS2RawPlugin struct {
tlsConfig *tls.Config
}
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.TLS2RawPluginOptions)
p := &TLS2RawPlugin{
opts: opts,
}
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
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)
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
+50 -22
View File
@@ -17,13 +17,13 @@ package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
goliblog "github.com/fatedier/golib/log"
charmlog "github.com/charmbracelet/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 {
bytes.Buffer
levels []goliblog.Level
mu sync.Mutex
buf bytes.Buffer
}
func (p testPlugin) Name() string {
@@ -55,9 +55,36 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
return p.handler(ctx, op, content)
}
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
w.levels = append(w.levels, level)
return w.Write(p)
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 captureLogOutput(t *testing.T) *logCapture {
@@ -66,11 +93,10 @@ func captureLogOutput(t *testing.T) *logCapture {
logCaptureMu.Lock()
logOutput := &logCapture{}
oldLogger := frplog.Logger
frplog.Logger = goliblog.New(
goliblog.WithOutput(logOutput),
goliblog.WithLevel(goliblog.TraceLevel),
goliblog.WithCaller(false),
)
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
Level: charmlog.DebugLevel,
Formatter: charmlog.JSONFormatter,
})
t.Cleanup(func() {
frplog.Logger = oldLogger
logCaptureMu.Unlock()
@@ -263,10 +289,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
tests := []struct {
name string
op string
level goliblog.Level
level charmlog.Level
}{
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
}
for _, tt := range tests {
@@ -288,8 +314,9 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
t.Fatalf("unexpected error: %v", err)
}
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())
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())
}
})
}
@@ -325,12 +352,13 @@ 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)
}
if len(logOutput.levels) != 2 {
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
levels := logOutput.levels()
if len(levels) != 2 {
t.Fatalf("expected two warning logs, got %v", levels)
}
for _, level := range logOutput.levels {
if level != goliblog.WarnLevel {
t.Fatalf("expected warning log level, got %v", logOutput.levels)
for _, level := range levels {
if level != charmlog.WarnLevel {
t.Fatalf("expected warning log level, got %v", levels)
}
}
}
+7 -3
View File
@@ -63,9 +63,13 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
// NewUDPPacket copies buf[:n], so the read buffer can be reused
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
select {
case sendCh <- udpMsg:
default:
if err = errors.PanicToError(func() {
select {
case sendCh <- udpMsg:
default:
}
}); err != nil {
return
}
}
}
+34
View File
@@ -1,9 +1,13 @@
package udp
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
)
func TestUdpPacket(t *testing.T) {
@@ -16,3 +20,33 @@ func TestUdpPacket(t *testing.T) {
require.NoError(err)
require.EqualValues(buf, newBuf)
}
func TestForwardUserConnReturnsWhenSendChannelIsClosed(t *testing.T) {
listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
readCh := make(chan *msg.UDPPacket)
sendCh := make(chan *msg.UDPPacket)
close(sendCh)
t.Cleanup(func() { close(readCh) })
done := make(chan struct{})
go func() {
ForwardUserConn(listener, readCh, sendCh, 1500)
close(done)
}()
sender, err := net.DialUDP("udp4", nil, listener.LocalAddr().(*net.UDPAddr))
require.NoError(t, err)
t.Cleanup(func() { _ = sender.Close() })
_, err = sender.Write([]byte("trigger"))
require.NoError(t, err)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ForwardUserConn did not return after sending to a closed channel")
}
}
+18 -1
View File
@@ -68,7 +68,8 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) {
return ServerHello{
Selected: ServerSelection{
Message: MessageSelection{
Codec: MessageCodecJSON,
Codec: MessageCodecJSON,
UDPPacketCodec: selectUDPPacketCodec(clientHello.Capabilities.Message.UDPPacketCodecs),
},
Crypto: CryptoSelection{
Algorithm: algorithm,
@@ -92,6 +93,15 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
if serverHello.Selected.Message.Codec != MessageCodecJSON {
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
}
udpPacketCodec := serverHello.Selected.Message.UDPPacketCodec
if udpPacketCodec != "" {
if udpPacketCodec != UDPPacketCodecBinary {
return fmt.Errorf("unsupported selected UDP packet codec: %s", udpPacketCodec)
}
if !Supports(clientHello.Capabilities.Message.UDPPacketCodecs, udpPacketCodec) {
return fmt.Errorf("selected UDP packet codec was not advertised by client: %s", udpPacketCodec)
}
}
cryptoSelection := serverHello.Selected.Crypto
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
@@ -105,6 +115,13 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
return nil
}
func selectUDPPacketCodec(codecs []string) string {
if Supports(codecs, UDPPacketCodecBinary) {
return UDPPacketCodecBinary
}
return ""
}
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
return &CryptoContext{
Algorithm: algorithm,
+7 -3
View File
@@ -36,6 +36,7 @@ const (
FrameTypeMessage uint16 = 16
MessageCodecJSON = "json"
UDPPacketCodecBinary = "binary-v1"
DefaultMaxFramePayloadSize = 64 * 1024
MagicV2 = "FRP\x00\x02\r\n"
@@ -182,7 +183,8 @@ type ClientCapabilities struct {
}
type MessageCapabilities struct {
Codecs []string `json:"codecs,omitempty"`
Codecs []string `json:"codecs,omitempty"`
UDPPacketCodecs []string `json:"udpPacketCodecs,omitempty"`
}
type CryptoCapabilities struct {
@@ -201,7 +203,8 @@ type ServerSelection struct {
}
type MessageSelection struct {
Codec string `json:"codec,omitempty"`
Codec string `json:"codec,omitempty"`
UDPPacketCodec string `json:"udpPacketCodec,omitempty"`
}
type CryptoSelection struct {
@@ -214,7 +217,8 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C
Bootstrap: bootstrap,
Capabilities: ClientCapabilities{
Message: MessageCapabilities{
Codecs: []string{MessageCodecJSON},
Codecs: []string{MessageCodecJSON},
UDPPacketCodecs: []string{UDPPacketCodecBinary},
},
Crypto: CryptoCapabilities{
Algorithms: PreferredAEADAlgorithms(),
+30
View File
@@ -148,10 +148,40 @@ func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
}
func TestUDPPacketCodecNegotiationFallbackAndValidation(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(hello, serverHello))
legacyHello := hello
legacyHello.Capabilities.Message.UDPPacketCodecs = nil
legacyServerHello, err := NewServerHello(legacyHello)
require.NoError(t, err)
require.Empty(t, legacyServerHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(legacyHello, legacyServerHello))
unknownOffer := hello
unknownOffer.Capabilities.Message.UDPPacketCodecs = []string{"unknown"}
unknownServerHello, err := NewServerHello(unknownOffer)
require.NoError(t, err)
require.Empty(t, unknownServerHello.Selected.Message.UDPPacketCodec)
rejected := serverHello
rejected.Selected.Message.UDPPacketCodec = "unknown"
require.ErrorContains(t, ValidateServerHelloForClient(hello, rejected), "unsupported selected UDP packet codec")
unadvertised := serverHello
unadvertised.Selected.Message.UDPPacketCodec = UDPPacketCodecBinary
require.ErrorContains(t, ValidateServerHelloForClient(legacyHello, unadvertised), "was not advertised")
}
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
+5
View File
@@ -70,6 +70,7 @@ type TunnelServer struct {
sshConn *ssh.ServerConn
sc *ssh.ServerConfig
firstChannel ssh.Channel
firstChannelMu sync.Mutex
vc *virtual.Client
peerServerListener *netpkg.InternalListener
@@ -191,6 +192,8 @@ func (s *TunnelServer) Run() error {
}
func (s *TunnelServer) writeToClient(data string) {
s.firstChannelMu.Lock()
defer s.firstChannelMu.Unlock()
if s.firstChannel == nil {
return
}
@@ -304,9 +307,11 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
if err != nil {
return
}
s.firstChannelMu.Lock()
if s.firstChannel == nil {
s.firstChannel = ch
}
s.firstChannelMu.Unlock()
go s.keepAlive(ch)
for req := range reqs {
+44
View File
@@ -16,7 +16,11 @@ package ssh
import (
"encoding/binary"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -69,3 +73,43 @@ func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) {
})
}
}
type trackingChannel struct {
active atomic.Int32
concurrent atomic.Bool
}
func (c *trackingChannel) Read([]byte) (int, error) { return 0, io.EOF }
func (c *trackingChannel) Write(p []byte) (int, error) {
if c.active.Add(1) != 1 {
c.concurrent.Store(true)
}
time.Sleep(time.Millisecond)
c.active.Add(-1)
return len(p), nil
}
func (c *trackingChannel) Close() error { return nil }
func (c *trackingChannel) CloseWrite() error { return nil }
func (c *trackingChannel) SendRequest(string, bool, []byte) (bool, error) { return false, nil }
func (c *trackingChannel) Stderr() io.ReadWriter { return nil }
func TestWriteToClientSerializesChannelWrites(t *testing.T) {
channel := &trackingChannel{}
s := &TunnelServer{firstChannel: channel}
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
<-start
s.writeToClient("message")
})
}
close(start)
wg.Wait()
if channel.concurrent.Load() {
t.Fatal("channel writes were concurrent")
}
}
+18
View File
@@ -0,0 +1,18 @@
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())
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package limit
import (
"fmt"
"golang.org/x/time/rate"
)
// NewBandwidthLimiter creates a limiter whose rate preserves the configured
// byte limit while keeping the burst representable as an int on all targets.
func NewBandwidthLimiter(bytes int64) *rate.Limiter {
if bytes <= 0 {
return nil
}
maxInt := int64(^uint(0) >> 1)
burst := min(bytes, maxInt)
return rate.NewLimiter(rate.Limit(float64(bytes)), int(burst))
}
func invalidBurstError(burst int) error {
return fmt.Errorf("invalid limiter burst: %d", burst)
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package limit
import (
"bytes"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
)
func TestNewBandwidthLimiterClampsBurstToTargetInt(t *testing.T) {
const bytesPerSecond = int64(1 << 31)
limiter := NewBandwidthLimiter(bytesPerSecond)
require.NotNil(t, limiter)
wantBurst := bytesPerSecond
maxInt := int64(^uint(0) >> 1)
if wantBurst > maxInt {
wantBurst = maxInt
}
require.Equal(t, int(wantBurst), limiter.Burst())
require.Equal(t, rate.Limit(float64(bytesPerSecond)), limiter.Limit())
}
func TestNewBandwidthLimiterDisablesNonPositiveLimit(t *testing.T) {
require.Nil(t, NewBandwidthLimiter(0))
require.Nil(t, NewBandwidthLimiter(-1))
}
func TestReaderAndWriterRejectInvalidBurst(t *testing.T) {
for _, burst := range []int{0, -1} {
t.Run("reader/"+strconv.Itoa(burst), func(t *testing.T) {
reader := NewReader(strings.NewReader("payload"), rate.NewLimiter(rate.Limit(1), burst))
n, err := reader.Read(make([]byte, 1))
require.Zero(t, n)
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
})
t.Run("writer/"+strconv.Itoa(burst), func(t *testing.T) {
var dst bytes.Buffer
writer := NewWriter(&dst, rate.NewLimiter(rate.Limit(1), burst))
n, err := writer.Write([]byte("payload"))
require.Zero(t, n)
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
require.Empty(t, dst.Bytes())
})
}
}
+6
View File
@@ -35,6 +35,12 @@ func NewReader(r io.Reader, limiter *rate.Limiter) *Reader {
func (r *Reader) Read(p []byte) (n int, err error) {
b := r.limiter.Burst()
if b <= 0 {
if len(p) == 0 {
return 0, nil
}
return 0, invalidBurstError(b)
}
if b < len(p) {
p = p[:b]
}
+7
View File
@@ -34,8 +34,15 @@ func NewWriter(w io.Writer, limiter *rate.Limiter) *Writer {
}
func (w *Writer) Write(p []byte) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
var nn int
b := w.limiter.Burst()
if b <= 0 {
return 0, invalidBurstError(b)
}
for {
end := len(p)
if end == 0 {
+157 -27
View File
@@ -16,13 +16,18 @@ package log
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatedier/golib/log"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
var (
TraceLevel = log.TraceLevel
TraceLevel = log.DebugLevel
DebugLevel = log.DebugLevel
InfoLevel = log.InfoLevel
WarnLevel = log.WarnLevel
@@ -32,39 +37,157 @@ var (
var Logger *log.Logger
func init() {
Logger = log.New(
log.WithCaller(true),
log.AddCallerSkip(1),
log.WithLevel(log.InfoLevel),
)
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)
}
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
options := []log.Option{}
var output io.Writer
var err error
if logPath == "console" {
if !disableLogColor {
options = append(options,
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
Colorful: true,
}, os.Stdout)),
)
}
output = os.Stdout
} else {
writer := log.NewRotateFileWriter(log.RotateFileConfig{
FileName: logPath,
Mode: log.RotateFileModeDaily,
MaxDays: maxDays,
})
writer.Init()
options = append(options, log.WithOutput(writer))
// Use rotating file writer
output, err = NewRotateFileWriter(logPath, maxDays)
if err != nil {
// Fallback to console if file creation fails
output = os.Stdout
}
}
level, err := log.ParseLevel(levelStr)
if err != nil {
level = log.InfoLevel
}
options = append(options, log.WithLevel(level))
Logger = Logger.WithOptions(options...)
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
}
func Errorf(format string, v ...any) {
@@ -75,6 +198,10 @@ 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...)
}
@@ -84,11 +211,12 @@ func Debugf(format string, v ...any) {
}
func Tracef(format string, v ...any) {
Logger.Tracef(format, v...)
Logger.Logf(TraceLevel, format, v...)
}
func Logf(level log.Level, offset int, format string, v ...any) {
Logger.Logf(level, offset, format, v...)
// charmbracelet/log doesn't support offset, so we ignore it
Logger.Logf(level, format, v...)
}
type WriteLogger struct {
@@ -104,6 +232,8 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
}
func (w *WriteLogger) Write(p []byte) (n int, err error) {
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
// charmbracelet/log doesn't support offset in Log
msg := string(bytes.TrimRight(p, "\n"))
Logger.Log(w.level, msg)
return len(p), nil
}
+6
View File
@@ -17,9 +17,15 @@ 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
@@ -0,0 +1,181 @@
// 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 }
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "0.70.1"
var version = "LoliaFRP-CLI 0.71.0"
func Full() string {
return version
+13 -2
View File
@@ -37,11 +37,15 @@ var ErrNoRouteFound = errors.New("no route found")
type HTTPReverseProxyOptions struct {
ResponseHeaderTimeoutS int64
// HTTPSRedirector, if set, redirects requests whose host has no HTTP
// route but opted into HTTP to HTTPS redirection.
HTTPSRedirector *HTTPSRedirector
}
type HTTPReverseProxy struct {
proxy http.Handler
vhostRouter *Routers
proxy http.Handler
vhostRouter *Routers
httpsRedirector *HTTPSRedirector
responseHeaderTimeout time.Duration
}
@@ -53,6 +57,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
rp := &HTTPReverseProxy{
responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,
vhostRouter: vhostRouter,
httpsRedirector: option.HTTPSRedirector,
}
proxy := &httputil.ReverseProxy{
// Modify incoming requests by route policies.
@@ -279,6 +284,12 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request)
return
}
// Hosts without any real HTTP route may still ask for a redirect to
// their HTTPS endpoint; registered HTTP routes always take precedence.
if rc == nil && rp.httpsRedirector != nil && rp.httpsRedirector.Redirect(rw, newreq) {
return
}
if req.Method == http.MethodConnect {
rp.connectHandler(rw, newreq)
} else {
+93
View File
@@ -0,0 +1,93 @@
// 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 vhost
import (
"net"
"net/http"
"net/url"
"strconv"
"sync"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
// HTTPSRedirector tracks domains of HTTPS proxies that opted into automatic
// HTTP to HTTPS redirection. The HTTP reverse proxy consults it as a fallback
// for hosts that have no real HTTP route, so registered HTTP proxies on the
// same domain always take precedence over the redirect.
type HTTPSRedirector struct {
mu sync.RWMutex
domains map[string]int // domain -> number of proxies requesting the redirect
port int // port used in the redirect Location, as seen by browsers
}
func NewHTTPSRedirector(port int) *HTTPSRedirector {
return &HTTPSRedirector{
domains: make(map[string]int),
port: port,
}
}
func (r *HTTPSRedirector) Add(domain string) {
r.mu.Lock()
defer r.mu.Unlock()
r.domains[domain]++
}
func (r *HTTPSRedirector) Remove(domain string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.domains[domain] <= 1 {
delete(r.domains, domain)
} else {
r.domains[domain]--
}
}
func (r *HTTPSRedirector) match(host string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.domains[host] > 0
}
// Redirect responds with a redirect to the HTTPS endpoint of the requested
// host if the host opted into redirection. It reports whether the request
// was handled.
func (r *HTTPSRedirector) Redirect(rw http.ResponseWriter, req *http.Request) bool {
if req.Method == http.MethodConnect {
return false
}
host, err := httppkg.CanonicalHost(req.Host)
if err != nil || !r.match(host) {
return false
}
target := url.URL{
Scheme: "https",
Host: host,
Path: req.URL.Path,
RawPath: req.URL.RawPath,
RawQuery: req.URL.RawQuery,
}
if r.port != 443 {
target.Host = net.JoinHostPort(host, strconv.Itoa(r.port))
}
// Not an open redirect: the host is validated against the registered
// domain set above, the scheme is fixed, and only the same-host path and
// query are echoed back.
http.Redirect(rw, req, target.String(), http.StatusMovedPermanently) //nolint:gosec // G710
return true
}
+46
View File
@@ -0,0 +1,46 @@
package vhost
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestHTTPSRedirectorRedirect(t *testing.T) {
r := NewHTTPSRedirector(443)
r.Add("a.example.com")
// registered domain, port stripped from host, path and query preserved
req := httptest.NewRequest("GET", "http://a.example.com:8080/foo?x=1", nil)
rw := httptest.NewRecorder()
require.True(t, r.Redirect(rw, req))
require.Equal(t, 301, rw.Code)
require.Equal(t, "https://a.example.com/foo?x=1", rw.Header().Get("Location"))
// unknown domain is not handled
req = httptest.NewRequest("GET", "http://b.example.com/", nil)
require.False(t, r.Redirect(httptest.NewRecorder(), req))
}
func TestHTTPSRedirectorNonDefaultPort(t *testing.T) {
r := NewHTTPSRedirector(8443)
r.Add("a.example.com")
req := httptest.NewRequest("GET", "http://a.example.com/", nil)
rw := httptest.NewRecorder()
require.True(t, r.Redirect(rw, req))
require.Equal(t, "https://a.example.com:8443/", rw.Header().Get("Location"))
}
func TestHTTPSRedirectorRefCount(t *testing.T) {
r := NewHTTPSRedirector(443)
r.Add("a.example.com")
r.Add("a.example.com")
r.Remove("a.example.com")
require.True(t, r.match("a.example.com"), "domain removed while another proxy still references it")
r.Remove("a.example.com")
require.False(t, r.match("a.example.com"))
}
+62 -15
View File
@@ -28,23 +28,70 @@ var NotFoundPagePath = ""
const (
NotFound = `<!DOCTYPE html>
<html>
<html lang="zh-CN">
<head>
<title>Not Found</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 未绑定域名</title>
<style>
body {
font-family: -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #fff;
color: #333;
}
.container {
max-width: 600px;
padding: 40px 20px;
text-align: center;
}
h1 {
font-size: 32px;
font-weight: 600;
margin-bottom: 20px;
}
p {
line-height: 1.8;
color: #666;
margin: 10px 0;
}
ul {
text-align: left;
margin: 20px auto;
max-width: 400px;
}
li {
margin: 8px 0;
color: #666;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover { text-decoration: underline; }
.footer {
margin-top: 40px;
font-size: 14px;
color: #999;
}
</style>
</head>
<body>
<h1>The page you requested was not found.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
<p><em>Faithfully yours, frp.</em></p>
<div class="container">
<h1>域名未绑定</h1>
<p>这个域名还没有绑定到任何隧道哦 (д)</p>
<p><strong>可能是这些原因</strong></p>
<ul>
<li>域名配置不对或者没有正确解析</li>
<li>隧道可能还没启动或者已经停止</li>
<li>自定义域名忘记在服务端配置了</li>
</ul>
<div class="footer"> <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
</div>
</body>
</html>
`
@@ -69,7 +116,7 @@ func getNotFoundPageContent() []byte {
func NotFoundResponse() *http.Response {
header := make(http.Header)
header.Set("server", "frp/"+version.Full())
header.Set("server", version.Full())
header.Set("Content-Type", "text/html")
content := getNotFoundPageContent()
+1 -1
View File
@@ -112,5 +112,5 @@ func (l *Logger) Debugf(format string, v ...any) {
}
func (l *Logger) Tracef(format string, v ...any) {
log.Logger.Tracef(l.prefixString+format, v...)
log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...)
}
+2 -1
View File
@@ -36,12 +36,13 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.Handle("/metrics", promhttp.Handler())
}
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
// apis
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
+74 -5
View File
@@ -17,6 +17,7 @@ package server
import (
"context"
"fmt"
"math"
"net"
"runtime/debug"
"sync"
@@ -45,6 +46,8 @@ type ControlID uint64
var nextControlID atomic.Uint64
const workConnPoolCapacityOffset = 10
type controlEntry struct {
ctl *Control
id ControlID
@@ -283,7 +286,7 @@ func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
// admitVisitorByRunID commits a visitor admission against the current running
// control while its run and lifecycle ownership are held. The callback must
// only perform the in-memory, buffered visitor admission.
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user string) error) (bool, error) {
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user, wireProtocol, udpPacketCodec string) error) (bool, error) {
entry, ok := cm.lockCurrentRun(runID, false)
if !ok {
return false, nil
@@ -296,7 +299,7 @@ func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user stri
if ctl.state != controlStateRunning {
return false, nil
}
return true, admit(ctl.sessionCtx.LoginMsg.User)
return true, admit(ctl.sessionCtx.LoginMsg.User, ctl.sessionCtx.WireProtocol, ctl.sessionCtx.UDPPacketCodec)
}
// RegisterWorkConn transfers conn to ctl only if ctl is still the current
@@ -349,6 +352,53 @@ func (cm *ControlManager) Close() error {
return nil
}
// CloseAllProxyByName Finds the tunnel name and closes all tunnels on the same connection.
func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
cm.mu.RLock()
var target *Control
for _, entry := range cm.ctlsByRunID {
ctl := entry.ctl
ctl.mu.RLock()
_, ok := ctl.proxies[proxyName]
ctl.mu.RUnlock()
if ok {
target = ctl
break
}
}
cm.mu.RUnlock()
if target == nil {
return fmt.Errorf("no proxy found with name [%s]", proxyName)
}
return target.Close()
}
// KickByProxyName finds the Control that manages the given proxy (tunnel) name and closes
// Bug: The client does not display the kickout message.
func (cm *ControlManager) KickByProxyName(proxyName string) error {
cm.mu.RLock()
var target *Control
for _, entry := range cm.ctlsByRunID {
ctl := entry.ctl
ctl.mu.RLock()
_, ok := ctl.proxies[proxyName]
ctl.mu.RUnlock()
if ok {
target = ctl
break
}
}
cm.mu.RUnlock()
if target == nil {
return fmt.Errorf("no proxy found with name [%s]", proxyName)
}
xl := target.xl
xl.Infof("kick client with proxy [%s] by server administrator request", proxyName)
return target.Close()
}
// SessionContext encapsulates the input parameters for creating a new Control.
type SessionContext struct {
// all resource managers and controllers
@@ -368,7 +418,8 @@ type SessionContext struct {
// server configuration
ServerCfg *v1.ServerConfig
// negotiated wire protocol for this client session
WireProtocol string
WireProtocol string
UDPPacketCodec string
}
type controlState uint8
@@ -430,10 +481,27 @@ type Control struct {
}
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
if sessionCtx.LoginMsg.PoolCount < 0 {
return nil, fmt.Errorf("invalid pool count %d, must be non-negative", sessionCtx.LoginMsg.PoolCount)
}
if sessionCtx.ServerCfg.Transport.MaxPoolCount < 0 {
return nil, fmt.Errorf(
"invalid max pool count %d, must be non-negative",
sessionCtx.ServerCfg.Transport.MaxPoolCount,
)
}
effectivePoolCount := min(int64(sessionCtx.LoginMsg.PoolCount), sessionCtx.ServerCfg.Transport.MaxPoolCount)
maxPoolCountForChannel := int64(math.MaxInt) - int64(workConnPoolCapacityOffset)
if effectivePoolCount > maxPoolCountForChannel {
return nil, fmt.Errorf(
"invalid effective pool count %d, cannot safely add %d for work connection pool capacity",
effectivePoolCount, workConnPoolCapacityOffset,
)
}
poolCount := int(effectivePoolCount)
ctl := &Control{
sessionCtx: sessionCtx,
workConnCh: make(chan *proxy.WorkConn, poolCount+10),
workConnCh: make(chan *proxy.WorkConn, poolCount+workConnPoolCapacityOffset),
proxies: make(map[string]proxy.Proxy),
poolCount: poolCount,
portsUsedNum: 0,
@@ -821,6 +889,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
ServerCfg: ctl.sessionCtx.ServerCfg,
EncryptionKey: ctl.sessionCtx.EncryptionKey,
WireProtocol: ctl.sessionCtx.WireProtocol,
UDPPacketCodec: ctl.sessionCtx.UDPPacketCodec,
})
if err != nil {
return remoteAddr, err
+52
View File
@@ -17,6 +17,7 @@ package server
import (
"context"
"errors"
"math"
"net"
"os"
"sync"
@@ -52,6 +53,57 @@ func TestControlPendingReplacementFinishesWithoutStarting(t *testing.T) {
require.Equal(t, int64(0), metrics.closedClients())
}
func TestNewControlPoolCountBoundaries(t *testing.T) {
for _, tc := range []struct {
name string
poolCount int
maxPoolCount int64
wantErr string
wantPoolCount int
wantCapacity int
}{
{name: "negative pool count below offset", poolCount: -11, maxPoolCount: 5, wantErr: "invalid pool count"},
{name: "negative pool count at offset", poolCount: -10, maxPoolCount: 5, wantErr: "invalid pool count"},
{name: "negative pool count", poolCount: -1, maxPoolCount: 5, wantErr: "invalid pool count"},
{name: "zero pool count", poolCount: 0, maxPoolCount: 5, wantPoolCount: 0, wantCapacity: 10},
{name: "pool count capped", poolCount: 10, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15},
{name: "maximum int pool count capped", poolCount: math.MaxInt, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15},
{name: "negative maximum", poolCount: 1, maxPoolCount: -1, wantErr: "invalid max pool count"},
{name: "maximum int64 with small client pool", poolCount: 1, maxPoolCount: math.MaxInt64, wantPoolCount: 1, wantCapacity: 11},
{name: "maximum int client and server overflow", poolCount: math.MaxInt, maxPoolCount: math.MaxInt64, wantErr: "cannot safely add"},
} {
t.Run(tc.name, func(t *testing.T) {
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
cfg := &v1.ServerConfig{}
cfg.Transport.MaxPoolCount = tc.maxPoolCount
ctl, err := NewControl(context.Background(), &SessionContext{
RC: &controller.ResourceController{},
PxyManager: proxy.NewManager(),
PluginManager: plugin.NewManager(),
AuthVerifier: auth.AlwaysPassVerifier,
Conn: msgConn,
LoginMsg: &msg.Login{
RunID: "pool-count-run",
PoolCount: tc.poolCount,
},
ServerCfg: cfg,
})
if tc.wantErr != "" {
require.Nil(t, ctl)
require.ErrorContains(t, err, tc.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tc.wantPoolCount, ctl.poolCount)
require.Equal(t, tc.wantCapacity, cap(ctl.workConnCh))
require.NoError(t, ctl.Close())
})
}
}
func TestControlRunningReplacementFinishesInWorker(t *testing.T) {
clientRegistry := registry.NewClientRegistry()
manager := NewControlManager(clientRegistry)
+3
View File
@@ -50,6 +50,9 @@ type ResourceController struct {
// For HTTP proxies, forwarding HTTP requests
HTTPReverseProxy *vhost.HTTPReverseProxy
// Domains of HTTPS proxies that requested automatic HTTP to HTTPS redirection
HTTPSRedirector *vhost.HTTPSRedirector
// For HTTPS proxies, route requests to different clients by hostname and other information
VhostHTTPSMuxer *vhost.HTTPSMuxer
+25
View File
@@ -38,21 +38,28 @@ type Controller struct {
serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry
pxyManager ProxyManager
ctlManager ControlManager
}
type ProxyManager interface {
GetByName(name string) (proxy.Proxy, bool)
}
type ControlManager interface {
CloseAllProxyByName(proxyName string) error
}
func NewController(
serverCfg *v1.ServerConfig,
clientRegistry *registry.ClientRegistry,
pxyManager ProxyManager,
ctlManager ControlManager,
) *Controller {
return &Controller{
serverCfg: serverCfg,
clientRegistry: clientRegistry,
pxyManager: pxyManager,
ctlManager: ctlManager,
}
}
@@ -220,6 +227,24 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return proxyInfo, nil
}
// POST /api/proxy/:name/close
func (c *Controller) APICloseProxyByName(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name required")
}
if c.ctlManager == nil {
return nil, fmt.Errorf("control manager unavailable")
}
if err := c.ctlManager.CloseAllProxyByName(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
return httppkg.GeneralResponse{Code: 200, Msg: "ok"}, nil
}
// DELETE /api/proxies?status=offline
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
status := ctx.Query("status")
+10 -6
View File
@@ -35,6 +35,10 @@ import (
"github.com/fatedier/frp/server/registry"
)
type stubControlManager struct{}
func (stubControlManager) CloseAllProxyByName(string) error { return nil }
type v2EnvelopeForTest[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
@@ -137,7 +141,7 @@ func TestAPIV2SystemInfoEnvelope(t *testing.T) {
Force: true,
},
},
}, registry.NewClientRegistry(), serverproxy.NewManager())
}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/system/info")
@@ -226,7 +230,7 @@ func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
@@ -380,7 +384,7 @@ func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), stubControlManager{})
router := newV2TestRouter(controller)
encodedKey := url.PathEscape("url.client/a?b#c")
@@ -488,7 +492,7 @@ func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
@@ -561,7 +565,7 @@ func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
router := newV2TestRouter(controller)
encodedName := url.PathEscape(name)
@@ -831,7 +835,7 @@ func newV2TestController(t *testing.T) *Controller {
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
clientRegistry.MarkOfflineByRunID("run-b")
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), nil)
}
func newV2TestRouter(controller *Controller) *mux.Router {
+13 -5
View File
@@ -144,18 +144,26 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
})
}
name := pxy.GetName()
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
rwc = wrapCountingReadWriteCloser(rwc, func(bytes int64) {
metrics.Server.AddTrafficOut(name, proxyType, bytes)
}, func(bytes int64) {
metrics.Server.AddTrafficIn(name, proxyType, bytes)
})
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)
metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
workConn = netpkg.WrapCloseNotifyConn(workConn, func(error) {
pxy.updateStatsAfterClosedConn()
})
metrics.Server.OpenConnection(name, proxyType)
return
}
func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {
func (pxy *HTTPProxy) updateStatsAfterClosedConn() {
name := pxy.GetName()
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
metrics.Server.CloseConnection(name, proxyType)
metrics.Server.AddTrafficIn(name, proxyType, totalWrite)
metrics.Server.AddTrafficOut(name, proxyType, totalRead)
}
func (pxy *HTTPProxy) Close() {
+19
View File
@@ -31,6 +31,9 @@ func init() {
type HTTPSProxy struct {
*BaseProxy
cfg *v1.HTTPSProxyConfig
// domains registered for HTTP to HTTPS redirection, removed on Close
redirectDomains []string
}
func NewHTTPSProxy(baseProxy *BaseProxy) Proxy {
@@ -66,12 +69,28 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
}
if pxy.cfg.HTTPRedirect {
if pxy.rc.HTTPSRedirector != nil {
for _, domain := range domains {
pxy.rc.HTTPSRedirector.Add(domain)
}
pxy.redirectDomains = domains
xl.Infof("https proxy enabled http redirect for hosts %v", domains)
} else {
xl.Warnf("httpRedirect is enabled but frps has no vhostHTTPPort or vhostHTTPSPort, ignored")
}
}
pxy.startCommonTCPListenersHandler()
remoteAddr = strings.Join(addrs, ",")
return
}
func (pxy *HTTPSProxy) Close() {
for _, domain := range pxy.redirectDomains {
pxy.rc.HTTPSRedirector.Remove(domain)
}
pxy.redirectDomains = nil
pxy.BaseProxy.Close()
}
+47
View File
@@ -0,0 +1,47 @@
package proxy
import (
"sync/atomic"
"time"
)
// idleWatcher closes a proxied connection pair when no bytes have flowed in
// either direction for the configured timeout. Without it, endpoints that
// stay open at the TCP level but never send data again would pin the join
// goroutines and their transfer buffers forever.
type idleWatcher struct {
lastActive atomic.Int64 // unix nano of the last byte transferred
stopCh chan struct{}
}
func startIdleWatcher(timeout time.Duration, closeFn func()) *idleWatcher {
w := &idleWatcher{stopCh: make(chan struct{})}
w.touch()
go func() {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-w.stopCh:
return
case <-timer.C:
idle := time.Since(time.Unix(0, w.lastActive.Load()))
if idle >= timeout {
closeFn()
return
}
timer.Reset(timeout - idle)
}
}
}()
return w
}
func (w *idleWatcher) touch() {
w.lastActive.Store(time.Now().UnixNano())
}
func (w *idleWatcher) stop() {
close(w.stopCh)
}

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