Compare commits

..
8 Commits
Author SHA1 Message Date
fatedierandGitHub 4ec8de973f Merge pull request #5287 from fatedier/dev
bump version to v0.68.1
2026-04-14 01:28:33 +08:00
fatedierandGitHub 5bfcea3d0c merge dev to master (#5254)
* ci: bump github actions to latest major versions (#5251)

* docker: copy shared web directory for npm workspace builds
2026-03-20 15:54:26 +08:00
fatedierandGitHub 0a1b4ab21f Merge pull request #5249 from fatedier/dev
bump version
2026-03-20 13:56:28 +08:00
fatedierandGitHub 5f575b8442 Merge pull request #5147 from fatedier/dev
bump version
2026-01-31 14:01:40 +08:00
fatedierandGitHub a1348cdf00 bump version (#5112) 2026-01-04 14:54:13 +08:00
fatedierandGitHub 2f5e1f7945 Merge pull request #4999 from fatedier/dev
bump version
2025-09-25 20:23:42 +08:00
fatedierandGitHub 22ae8166d3 Merge pull request #4925 from fatedier/dev
bump version
2025-08-10 23:26:32 +08:00
fatedierandGitHub af6bc6369d Merge pull request #4849 from fatedier/dev
bump version
2025-06-25 11:51:19 +08:00
233 changed files with 3640 additions and 20808 deletions
+7 -8
View File
@@ -7,15 +7,14 @@ jobs:
steps:
- checkout
- run:
name: Test and build web assets
command: make web-ci
name: Build web assets (frps)
command: make install build
working_directory: web/frps
- run:
name: Check Go formatting and build binaries
command: |
set -e
make env fmt
git diff --exit-code
make build
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
- run: make alltest
workflows:
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [fatedier]
custom: ["https://afdian.com/a/fatedier"]
+3
View File
@@ -0,0 +1,3 @@
### WHY
<!-- author to complete -->
-194
View File
@@ -1,194 +0,0 @@
name: Build FRP Binaries
on:
push:
branches:
- '**'
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin, freebsd, openbsd, android]
goarch: [amd64, 386, arm, arm64]
include:
- goos: linux
goarch: loong64
exclude:
- goos: darwin
goarch: arm
- goos: darwin
goarch: 386
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: android
goarch: amd64
- goos: android
goarch: 386
# 排除 Android ARM 32位,在单独的 job 中处理
- goos: android
goarch: arm
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y zip tar make gcc g++ upx
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
run: |
mkdir -p release/packages
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
# 构建版本号
make
version=$(./bin/frps --version)
echo "Detected version: $version"
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
export CGO_ENABLED=0
# 构建可执行文件
make frpc frps
if [ "${{ matrix.goos }}" = "windows" ]; then
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
fi
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
mkdir -p "$out_dir"
if [ "${{ matrix.goos }}" = "windows" ]; then
mv ./bin/frpc.exe "$out_dir/frpc.exe"
mv ./bin/frps.exe "$out_dir/frps.exe"
else
mv ./bin/frpc "$out_dir/frpc"
mv ./bin/frps "$out_dir/frps"
fi
cp LICENSE "$out_dir"
cp -f conf/frpc.toml "$out_dir"
cp -f conf/frps.toml "$out_dir"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
path: |
release/packages/frp_*
retention-days: 7
build-android-arm:
name: Build FRP android-arm
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies and Android NDK
run: |
sudo apt-get update -y
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
# 下载并安装 Android NDK
echo "Downloading Android NDK..."
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
echo "Extracting Android NDK..."
unzip -q android-ndk-r26c-linux.zip
echo "NDK installed at: $PWD/android-ndk-r26c"
- name: Build FRP for android-arm
run: |
mkdir -p release/packages
mkdir -p bin
echo "Building for android-arm with CGO"
# 首先构建一次获取版本号
CGO_ENABLED=0 make
version=$(./bin/frps --version)
echo "Detected version: $version"
# 清理之前的构建
rm -rf ./bin/*
# 设置 Android ARM 交叉编译环境
export GOOS=android
export GOARCH=arm
export GOARM=7
export CGO_ENABLED=1
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
echo "Environment:"
echo "GOOS=$GOOS"
echo "GOARCH=$GOARCH"
echo "GOARM=$GOARM"
echo "CGO_ENABLED=$CGO_ENABLED"
echo "CC=$CC"
# 直接使用 go build 命令,不通过 Makefile,防止 CGO_ENABLED 被覆盖
# -checklinkname=0: 关闭 Go 1.23+ 的 linkname 检查,规避 wlynxg/anet
# 在 android 下通过 //go:linkname 引用 net.zoneCache 导致的链接失败
echo "Building frps..."
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frps -o bin/frps ./cmd/frps
echo "Building frpc..."
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frpc -o bin/frpc ./cmd/frpc
# 验证文件已生成
ls -lh ./bin/
file ./bin/frpc
file ./bin/frps
out_dir="release/packages/frp_${version}_android_arm"
mkdir -p "$out_dir"
mv ./bin/frpc "$out_dir/frpc"
mv ./bin/frps "$out_dir/frps"
cp LICENSE "$out_dir"
cp -f conf/frpc.toml "$out_dir"
cp -f conf/frps.toml "$out_dir"
echo "Build completed for android-arm"
ls -lh "$out_dir"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: LoliaFrp_android_arm
path: |
release/packages/frp_*
retention-days: 7
@@ -0,0 +1,83 @@
name: Build Image and Publish to Dockerhub & GPR
on:
release:
types: [ published ]
workflow_dispatch:
inputs:
tag:
description: 'Image tag'
required: true
default: 'test'
permissions:
contents: read
jobs:
image:
name: Build Image from Dockerfile and binaries
runs-on: ubuntu-latest
steps:
# environment
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: '0'
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# get image tag name
- name: Get Image Tag Name
run: |
if [ x${{ github.event.inputs.tag }} == x"" ]; then
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
else
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
fi
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to the GPR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GPR_TOKEN }}
# prepare image tags
- name: Prepare Image Tags
run: |
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
- name: Build and push frpc
uses: docker/build-push-action@v7
with:
context: .
file: ./dockerfiles/Dockerfile-for-frpc
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
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,linux/s390x
push: true
tags: |
${{ env.TAG_FRPS }}
${{ env.TAG_FRPS_GPR }}
-82
View File
@@ -1,82 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- dev
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
include:
- name: frps
dockerfile: dockerfiles/Dockerfile-for-frps
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
- name: frpc
dockerfile: dockerfiles/Dockerfile-for-frpc
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image_name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Generate image digest
if: github.event_name != 'pull_request'
run: |
echo "Image pushed to: ${{ matrix.image_name }}"
echo "Tags: ${{ steps.meta.outputs.tags }}"
+7 -3
View File
@@ -22,10 +22,14 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Test and build web assets
run: make web-ci
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: v2.11
version: v2.10
-129
View File
@@ -1,129 +0,0 @@
name: Release FRP Binaries
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to release (e.g., v1.0.0)"
required: true
type: string
permissions:
contents: write
jobs:
# 调用 build-all workflow
build:
uses: ./.github/workflows/build-all.yaml
permissions:
contents: read
# 创建 release
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get tag name
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display artifact structure
run: |
echo "Artifact structure:"
ls -R artifacts/
- name: Organize release files
run: |
mkdir -p release_files
# 查找并复制所有压缩包
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
# 如果没有压缩包,尝试查找二进制文件并打包
if [ -z "$(ls -A release_files/)" ]; then
echo "No archives found, looking for directories to package..."
for dir in artifacts/*/; do
if [ -d "$dir" ]; then
artifact_name=$(basename "$dir")
echo "Packaging $artifact_name"
# 检查是否是 Windows 构建
if echo "$artifact_name" | grep -q "windows"; then
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
else
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
fi
fi
done
fi
echo "Files in release_files:"
ls -lh release_files/
- name: Generate checksums
run: |
cd release_files
if [ -n "$(ls -A .)" ]; then
sha256sum * > sha256sum.txt
cat sha256sum.txt
else
echo "No files to generate checksums for!"
exit 1
fi
- name: Debug - Check tags and commits
run: |
echo "Current tag: ${{ steps.tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
echo "Previous tag: $PREV_TAG"
echo ""
echo "Commits between tags:"
if [ "$PREV_TAG" != "none" ]; then
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
else
echo "First tag, showing all commits:"
git log --oneline ${{ steps.tag.outputs.tag }}
fi
- name: Build Changelog
id: changelog
uses: requarks/changelog-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.tag.outputs.tag }}
writeToFile: false
includeInvalidCommits: true
useGitmojis: false
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Release ${{ steps.tag.outputs.tag }}
body: ${{ steps.changelog.outputs.changes }}
draft: false
prerelease: false
files: |
release_files/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-5
View File
@@ -18,7 +18,6 @@ release/
test/bin/
vendor/
lastversion/
.cache/
dist/
.idea/
.vscode/
@@ -32,10 +31,6 @@ node_modules/
*.swp
# AI
CLAUDE.md
.claude/
.sisyphus/
.superpowers/
# TLS
.autotls-cache
+1 -1
View File
@@ -34,7 +34,7 @@ linters:
disabled-checks:
- exitAfterDefer
gosec:
excludes: ["G115", "G117", "G118", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
excludes: ["G115", "G117", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
severity: low
confidence: low
govet:
+2 -20
View File
@@ -1,13 +1,9 @@
export PATH := $(PATH):`go env GOPATH`/bin
export GO111MODULE=on
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep),
# which uses //go:linkname to reference net.zoneCache on android targets.
LDFLAGS := -s -w -checklinkname=0
LDFLAGS := -s -w
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
.PHONY: web frps-web frpc-web frps frpc
all: env fmt web build
@@ -18,9 +14,6 @@ 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
@@ -60,15 +53,6 @@ e2e:
e2e-trace:
DEBUG=true LOG_LEVEL=trace ./hack/run-e2e.sh
e2e-compatibility-smoke: build
FRP_COMPAT_BASELINE_COUNT=1 ./hack/run-e2e-compatibility.sh
e2e-compatibility: build
FRP_COMPAT_BASELINE_COUNT="$(FRP_COMPAT_BASELINE_COUNT)" ./hack/run-e2e-compatibility.sh
e2e-compatibility-floor: build
FRP_COMPAT_BASELINE_VERSIONS="$(FRP_COMPAT_FLOOR_VERSION)" ./hack/run-e2e-compatibility.sh
e2e-compatibility-last-frpc:
if [ ! -d "./lastversion" ]; then \
TARGET_DIRNAME=lastversion ./hack/download.sh; \
@@ -89,5 +73,3 @@ clean:
rm -f ./bin/frpc
rm -f ./bin/frps
rm -rf ./lastversion
rm -rf ./.cache
rm -rf ./.compat
+1 -1
View File
@@ -9,7 +9,7 @@ all: build
build: app
app:
@set -e; $(foreach n, $(os-archs), \
@$(foreach n, $(os-archs), \
os=$(shell echo "$(n)" | cut -d : -f 1); \
arch=$(shell echo "$(n)" | cut -d : -f 2); \
extra=$(shell echo "$(n)" | cut -d : -f 3); \
+11
View File
@@ -2,6 +2,7 @@
[![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)
@@ -32,6 +33,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
</div>
<p align="center">
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
+11
View File
@@ -2,6 +2,7 @@
[![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)
@@ -34,6 +35,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
</div>
<p align="center">
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
+1 -7
View File
@@ -1,9 +1,3 @@
## 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
* Fixed a server panic and remote denial of service caused by a client sending a negative `pool_count`. Negative values are now rejected before work-connection pool resources are allocated.
* Fixed `frpc verify` ignoring configured `featureGates`, which caused VirtualNet configurations to be rejected even when the feature was enabled.
* Fixed a case-insensitive validation bypass that allowed `customDomains` under the configured `subDomainHost` to be registered using mixed-case domain names.
* Fixed a configuration-dependent authentication bypass in `type = "http"` proxies when `routeByHTTPUser` is used together with `httpUser` / `httpPassword`. This affected proxy-style requests. Proxy-style authentication failures now return `407 Proxy Authentication Required`.
-254
View File
@@ -2,16 +2,12 @@ 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 {
@@ -26,256 +22,6 @@ 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"),
-35
View File
@@ -29,8 +29,6 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/xlog"
@@ -43,39 +41,6 @@ type Connector interface {
Close() error
}
type MessageConnector interface {
Connect() (*msg.Conn, error)
Close() error
}
type messageConnector struct {
connector Connector
wireProtocol string
}
func newMessageConnector(connector Connector, wireProtocol string) *messageConnector {
return &messageConnector{
connector: connector,
wireProtocol: wireProtocol,
}
}
func (c *messageConnector) Connect() (*msg.Conn, error) {
conn, err := c.connector.Connect()
if err != nil {
return nil, err
}
if err = wire.WriteMagicIfV2(conn, c.wireProtocol); err != nil {
conn.Close()
return nil, err
}
return msg.NewConn(conn, msg.NewReadWriter(conn, c.wireProtocol)), nil
}
func (c *messageConnector) Close() error {
return c.connector.Close()
}
// defaultConnectorImpl is the default implementation of Connector for normal frpc.
type defaultConnectorImpl struct {
ctx context.Context
+22 -57
View File
@@ -16,9 +16,7 @@ package client
import (
"context"
"fmt"
"net"
"strings"
"sync/atomic"
"time"
@@ -29,6 +27,7 @@ import (
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -42,15 +41,15 @@ type SessionContext struct {
// It should be attached to the login message when reconnecting.
RunID string
// Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit.
Conn *msg.Conn
Conn net.Conn
// Indicates whether the connection is encrypted.
ConnEncrypted bool
// Auth runtime used for login, heartbeats, and encryption.
Auth *auth.ClientAuth
// Connector is used to create message connections to frps.
Connector MessageConnector
// Connector is used to create new connections, which could be real TCP connections or virtual streams.
Connector Connector
// Virtual net controller
VnetController *vnet.Controller
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
UDPPacketCodec string
}
type Control struct {
@@ -92,20 +91,21 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
}
ctl.lastPong.Store(time.Now())
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
if sessionCtx.ConnEncrypted {
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey())
if err != nil {
return nil, err
}
ctl.msgDispatcher = msg.NewDispatcher(cryptoRW)
} else {
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
}
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.pm = proxy.NewManager(
ctl.ctx,
sessionCtx.Common,
sessionCtx.Auth.EncryptionKey(),
ctl.msgTransporter,
sessionCtx.VnetController,
sessionCtx.UDPPacketCodec,
)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController, sessionCtx.UDPPacketCodec)
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
return ctl, nil
}
@@ -139,14 +139,14 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
workConn.Close()
return
}
if err = workConn.WriteMsg(m); err != nil {
if err = msg.WriteMsg(workConn, m); err != nil {
xl.Warnf("work connection write to server error: %v", err)
workConn.Close()
return
}
var startMsg msg.StartWorkConn
if err = workConn.ReadMsgInto(&startMsg); err != nil {
if err = msg.ReadMsgInto(workConn, &startMsg); err != nil {
xl.Tracef("work connection closed before response StartWorkConn message: %v", err)
workConn.Close()
return
@@ -171,44 +171,9 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
if err != nil {
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
xl.Warnf("[%s] start error: %v", proxyName, err)
} else {
xl.Infof("[%s] 成功启动隧道", proxyName)
if inMsg.RemoteAddr != "" {
// Get proxy type to format access message
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
proxyType := status.Type
remoteAddr := inMsg.RemoteAddr
var accessMsg string
switch proxyType {
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
if strings.HasPrefix(remoteAddr, ":") {
serverAddr := ctl.sessionCtx.Common.ServerAddr
remoteAddr = serverAddr + remoteAddr
}
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
case "http", "https":
// Format as URL with protocol
protocol := proxyType
addr := remoteAddr
// Remove standard ports for cleaner URL
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
addr = strings.TrimSuffix(addr, ":80")
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
addr = strings.TrimSuffix(addr, ":443")
}
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
default:
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
}
xl.Infof("[%s] %s", proxyName, accessMsg)
} else {
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
}
}
xl.Infof("[%s] start proxy success", proxyName)
}
}
@@ -262,7 +227,7 @@ func (ctl *Control) Done() <-chan struct{} {
}
// connectServer return a new connection to frps
func (ctl *Control) connectServer() (*msg.Conn, error) {
func (ctl *Control) connectServer() (net.Conn, error) {
return ctl.sessionCtx.Connector.Connect()
}
-225
View File
@@ -1,225 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"time"
"github.com/samber/lo"
"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"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/vnet"
)
type controlSessionDialer struct {
ctx context.Context
common *v1.ClientCommonConfig
auth *auth.ClientAuth
clientSpec *msg.ClientSpec
vnetController *vnet.Controller
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
}
func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, error) {
connector := d.connectorCreator(d.ctx, d.common)
if err := connector.Open(); err != nil {
return nil, err
}
success := false
defer func() {
if !success {
_ = connector.Close()
}
}()
conn, err := connector.Connect()
if err != nil {
return nil, err
}
defer func() {
if !success {
_ = conn.Close()
}
}()
loginMsg, err := d.buildLoginMsg(previousRunID)
if err != nil {
return nil, err
}
loginResult, err := d.exchangeLogin(conn, loginMsg)
if err != nil {
return nil, err
}
loginRespMsg := loginResult.resp
if loginRespMsg.Error != "" {
return nil, errors.New(loginRespMsg.Error)
}
var controlRW io.ReadWriter = conn
if d.clientSpec == nil || d.clientSpec.Type != "ssh-tunnel" {
controlRW, err = d.newControlReadWriter(conn, loginResult.crypto)
if err != nil {
return nil, fmt.Errorf("create control crypto read writer: %w", err)
}
}
success = true
return &SessionContext{
Common: d.common,
RunID: loginRespMsg.RunID,
Conn: msg.NewConn(conn, msg.NewReadWriter(controlRW, d.common.Transport.WireProtocol)),
Auth: d.auth,
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
VnetController: d.vnetController,
UDPPacketCodec: loginResult.udpPacketCodec,
}, nil
}
func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login, error) {
hostname, _ := os.Hostname()
loginMsg := &msg.Login{
Arch: runtime.GOARCH,
Os: runtime.GOOS,
Hostname: hostname,
PoolCount: d.common.Transport.PoolCount,
User: d.common.User,
ClientID: d.common.ClientID,
Version: version.Full(),
Timestamp: time.Now().Unix(),
RunID: previousRunID,
Metas: d.common.Metadatas,
}
if d.clientSpec != nil {
loginMsg.ClientSpec = *d.clientSpec
}
if err := d.auth.Setter.SetLogin(loginMsg); err != nil {
return nil, err
}
return loginMsg, nil
}
type loginExchangeResult struct {
resp *msg.LoginResp
crypto *wire.CryptoContext
udpPacketCodec string
}
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
rw := msg.NewV1ReadWriter(conn)
var wireConn *wire.Conn
var clientHello wire.ClientHello
var clientHelloPayload []byte
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
if err := wire.WriteMagic(conn); err != nil {
return nil, err
}
wireConn = wire.NewConn(conn)
rw = msg.NewV2ReadWriterWithConn(wireConn)
var err error
clientHello, err = wire.NewClientHello(wire.BootstrapInfo{
Transport: d.common.Transport.Protocol,
TLS: lo.FromPtr(d.common.Transport.TLS.Enable) || d.common.Transport.Protocol == "wss" || d.common.Transport.Protocol == "quic",
TCPMux: lo.FromPtr(d.common.Transport.TCPMux),
})
if err != nil {
return nil, err
}
clientHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeClientHello, clientHello)
if err != nil {
return nil, err
}
if err := wireConn.WriteFrame(clientHelloFrame); err != nil {
return nil, err
}
clientHelloPayload = clientHelloFrame.Payload
}
if err := rw.WriteMsg(loginMsg); err != nil {
return nil, err
}
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
defer func() {
_ = conn.SetReadDeadline(time.Time{})
}()
var cryptoContext *wire.CryptoContext
var udpPacketCodec string
if wireConn != nil {
serverHelloFrame, err := wireConn.ReadFrame()
if err != nil {
return nil, err
}
if serverHelloFrame.Type != wire.FrameTypeServerHello {
return nil, fmt.Errorf("unexpected frame type %d, want %d", serverHelloFrame.Type, wire.FrameTypeServerHello)
}
var serverHello wire.ServerHello
if err := wireConn.UnmarshalFrame(serverHelloFrame, &serverHello); err != nil {
return nil, err
}
if serverHello.Error != "" {
return nil, errors.New(serverHello.Error)
}
cryptoContext, err = wire.NewClientCryptoContext(clientHelloPayload, serverHelloFrame.Payload)
if err != nil {
return nil, err
}
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
}
var loginRespMsg msg.LoginResp
if err := rw.ReadMsgInto(&loginRespMsg); err != nil {
return nil, err
}
return &loginExchangeResult{
resp: &loginRespMsg,
crypto: cryptoContext,
udpPacketCodec: udpPacketCodec,
}, nil
}
func (d *controlSessionDialer) newControlReadWriter(conn net.Conn, cryptoContext *wire.CryptoContext) (io.ReadWriter, error) {
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
if cryptoContext == nil {
return nil, errors.New("missing v2 crypto negotiation")
}
return netpkg.NewAEADCryptoReadWriter(
conn,
d.auth.EncryptionKey(),
netpkg.AEADCryptoRoleClient,
cryptoContext.Algorithm,
cryptoContext.TranscriptHash,
)
}
return netpkg.NewCryptoReadWriter(conn, d.auth.EncryptionKey())
}
-299
View File
@@ -1,299 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"context"
"fmt"
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"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"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
type testConnector struct {
conn net.Conn
closed atomic.Bool
}
func (c *testConnector) Open() error {
return nil
}
func (c *testConnector) Connect() (net.Conn, error) {
return c.conn, nil
}
func (c *testConnector) Close() error {
c.closed.Store(true)
return nil
}
type trackingConn struct {
net.Conn
closed atomic.Bool
}
func (c *trackingConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
func newTestControlSessionDialer(t *testing.T, protocol string, connector Connector, clientSpec *msg.ClientSpec) *controlSessionDialer {
t.Helper()
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
Method: v1.AuthMethodToken,
Token: "token",
})
require.NoError(t, err)
return &controlSessionDialer{
ctx: context.Background(),
common: &v1.ClientCommonConfig{
User: "test-user",
Transport: v1.ClientTransportConfig{
Protocol: "tcp",
WireProtocol: protocol,
},
},
auth: authRuntime,
clientSpec: clientSpec,
connectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
return connector
},
}
}
func TestControlSessionDialerDialV1(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer serverRaw.Close()
connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
serverErrCh := make(chan error, 1)
go func() {
rw := msg.NewV1ReadWriter(serverRaw)
var loginMsg msg.Login
if err := rw.ReadMsgInto(&loginMsg); err != nil {
serverErrCh <- err
return
}
if loginMsg.RunID != "previous-run-id" {
serverErrCh <- fmt.Errorf("unexpected previous run id: %s", loginMsg.RunID)
return
}
if loginMsg.User != "test-user" {
serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
return
}
serverErrCh <- rw.WriteMsg(&msg.LoginResp{RunID: "run-v1"})
}()
dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, nil)
sessionCtx, err := dialer.Dial("previous-run-id")
require.NoError(t, err)
defer sessionCtx.Conn.Close()
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())
require.NoError(t, <-serverErrCh)
}
func TestControlSessionDialerDialV2(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer serverRaw.Close()
connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
serverErrCh := make(chan error, 1)
go func() {
magic := make([]byte, len(wire.MagicV2))
if _, err := io.ReadFull(serverRaw, magic); err != nil {
serverErrCh <- err
return
}
if string(magic) != wire.MagicV2 {
serverErrCh <- fmt.Errorf("unexpected magic: %q", string(magic))
return
}
wireConn := wire.NewConn(serverRaw)
clientHelloFrame, err := wireConn.ReadFrame()
if err != nil {
serverErrCh <- err
return
}
if clientHelloFrame.Type != wire.FrameTypeClientHello {
serverErrCh <- fmt.Errorf("unexpected frame type %d, want %d", clientHelloFrame.Type, wire.FrameTypeClientHello)
return
}
var hello wire.ClientHello
if err := wireConn.UnmarshalFrame(clientHelloFrame, &hello); err != nil {
serverErrCh <- err
return
}
if err := wire.ValidateClientHello(hello); err != nil {
serverErrCh <- err
return
}
rw := msg.NewV2ReadWriterWithConn(wireConn)
var loginMsg msg.Login
if err := rw.ReadMsgInto(&loginMsg); err != nil {
serverErrCh <- err
return
}
if loginMsg.User != "test-user" {
serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
return
}
serverHello, err := wire.NewServerHello(hello)
if err != nil {
serverErrCh <- err
return
}
serverHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeServerHello, serverHello)
if err != nil {
serverErrCh <- err
return
}
cryptoContext := wire.NewCryptoContext(
serverHello.Selected.Crypto.Algorithm,
clientHelloFrame.Payload,
serverHelloFrame.Payload,
)
if err := wireConn.WriteFrame(serverHelloFrame); err != nil {
serverErrCh <- err
return
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-v2"}); err != nil {
serverErrCh <- err
return
}
controlRW, err := netpkg.NewAEADCryptoReadWriter(
serverRaw,
[]byte("token"),
netpkg.AEADCryptoRoleServer,
cryptoContext.Algorithm,
cryptoContext.TranscriptHash,
)
if err != nil {
serverErrCh <- err
return
}
controlMsgRW := msg.NewReadWriter(controlRW, wire.ProtocolV2)
var ping msg.Ping
if err := controlMsgRW.ReadMsgInto(&ping); err != nil {
serverErrCh <- err
return
}
if ping.PrivilegeKey != "v2-ping" || ping.Timestamp != 12345 {
serverErrCh <- fmt.Errorf("unexpected ping: %+v", ping)
return
}
serverErrCh <- nil
}()
dialer := newTestControlSessionDialer(t, wire.ProtocolV2, connector, nil)
sessionCtx, err := dialer.Dial("")
require.NoError(t, err)
defer sessionCtx.Conn.Close()
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())
require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{PrivilegeKey: "v2-ping", Timestamp: 12345}))
require.NoError(t, <-serverErrCh)
}
func TestControlSessionDialerDialLoginErrorClosesResources(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer serverRaw.Close()
clientConn := &trackingConn{Conn: clientRaw}
connector := &testConnector{conn: clientConn}
serverErrCh := make(chan error, 1)
go func() {
rw := msg.NewV1ReadWriter(serverRaw)
var loginMsg msg.Login
if err := rw.ReadMsgInto(&loginMsg); err != nil {
serverErrCh <- err
return
}
serverErrCh <- rw.WriteMsg(&msg.LoginResp{Error: "login denied"})
}()
dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, nil)
sessionCtx, err := dialer.Dial("")
require.Nil(t, sessionCtx)
require.ErrorContains(t, err, "login denied")
require.True(t, clientConn.closed.Load())
require.True(t, connector.closed.Load())
require.NoError(t, <-serverErrCh)
}
func TestControlSessionDialerDialSSHTunnelSkipsControlEncryption(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer serverRaw.Close()
connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
serverErrCh := make(chan error, 1)
go func() {
rw := msg.NewV1ReadWriter(serverRaw)
var loginMsg msg.Login
if err := rw.ReadMsgInto(&loginMsg); err != nil {
serverErrCh <- err
return
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-ssh-tunnel"}); err != nil {
serverErrCh <- err
return
}
_ = serverRaw.SetReadDeadline(time.Now().Add(time.Second))
var ping msg.Ping
if err := rw.ReadMsgInto(&ping); err != nil {
serverErrCh <- err
return
}
serverErrCh <- nil
}()
dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, &msg.ClientSpec{Type: "ssh-tunnel"})
sessionCtx, err := dialer.Dial("")
require.NoError(t, err)
defer sessionCtx.Conn.Close()
defer sessionCtx.Connector.Close()
require.Equal(t, "run-ssh-tunnel", sessionCtx.RunID)
require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{}))
require.NoError(t, <-serverErrCh)
}
-125
View File
@@ -1,125 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//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)
}
+9 -65
View File
@@ -20,9 +20,7 @@ import (
"io"
"net"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -63,17 +61,15 @@ 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 = limit.NewBandwidthLimiter(limitBytes)
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
}
baseProxy := BaseProxy{
baseCfg: pxyConf.GetBaseConfig(),
configurer: pxyConf,
clientCfg: clientCfg,
encryptionKey: encryptionKey,
limiter: limiter,
@@ -81,7 +77,6 @@ func NewProxy(
vnetController: vnetController,
xl: xlog.FromContextSafe(ctx),
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
@@ -93,7 +88,6 @@ func NewProxy(
type BaseProxy struct {
baseCfg *v1.ProxyBaseConfig
configurer v1.ProxyConfigurer
clientCfg *v1.ClientCommonConfig
encryptionKey []byte
msgTransporter transport.MessageTransporter
@@ -104,17 +98,15 @@ 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
udpPacketCodec string
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
}
func (pxy *BaseProxy) Run() error {
if pxy.baseCfg.Plugin.Type != "" {
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
Name: pxy.baseCfg.Name,
HostAllowList: pxy.getPluginHostAllowList(),
VnetController: pxy.vnetController,
}, pxy.baseCfg.Plugin.ClientPluginOptions)
if err != nil {
@@ -125,39 +117,6 @@ 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()
@@ -212,26 +171,6 @@ 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)
@@ -241,6 +180,11 @@ 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
}
+4 -7
View File
@@ -43,8 +43,7 @@ type Manager struct {
encryptionKey []byte
clientCfg *v1.ClientCommonConfig
ctx context.Context
udpPacketCodec string
ctx context.Context
}
func NewManager(
@@ -53,7 +52,6 @@ func NewManager(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Manager {
return &Manager{
proxies: make(map[string]*Wrapper),
@@ -63,7 +61,6 @@ func NewManager(
encryptionKey: encryptionKey,
clientCfg: clientCfg,
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
}
@@ -162,14 +159,14 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
}
}
if len(delPxyNames) > 0 {
xl.Infof("隧道移除: %s", delPxyNames)
xl.Infof("proxy removed: %s", delPxyNames)
}
addPxyNames := make([]string, 0)
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, pm.udpPacketCodec)
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
}
@@ -180,6 +177,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
}
}
if len(addPxyNames) > 0 {
xl.Infof("添加隧道: %s", addPxyNames)
xl.Infof("proxy added: %s", addPxyNames)
}
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//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)
}
+1 -2
View File
@@ -99,7 +99,6 @@ 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)
@@ -128,7 +127,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor")
}
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec)
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
return pw
}
+7 -14
View File
@@ -87,13 +87,6 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
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
@@ -116,7 +109,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
defer closeFn()
for {
@@ -129,7 +122,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
var udpMsg msg.UDPPacket
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
xl.Warnf("read from workConn for sudp error: %v", errRet)
return
}
@@ -144,7 +137,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service -> frpc -> frps -> frpc visitor -> user
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
defer func() {
closeFn()
xl.Infof("writer goroutine for sudp work connection closed")
@@ -155,12 +148,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
case *msg.Ping:
xl.Tracef("frpc send ping message to frpc visitor")
}
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
xl.Errorf("sudp work write error: %v", errRet)
return
}
@@ -191,8 +184,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(payloadConn, sendCh)
go workConnReaderFn(payloadConn, readCh)
go workConnSenderFn(workConn, sendCh)
go workConnReaderFn(workConn, readCh)
go heartbeatFn(sendCh)
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
+8 -17
View File
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
xl := pxy.xl
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
// close resources related with old workConn
pxy.Close()
@@ -97,26 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing.
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.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
pxy.mu.Unlock()
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
for {
var udpMsg msg.UDPPacket
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
return
}
@@ -129,7 +120,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
}
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
defer func() {
xl.Infof("writer goroutine for udp work connection closed")
}()
@@ -141,7 +132,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
xl.Errorf("udp work write error: %v", errRet)
return
}
@@ -160,8 +151,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(payloadRW, pxy.sendCh)
go workConnReaderFn(payloadRW, pxy.readCh)
go workConnSenderFn(pxy.workConn, pxy.sendCh)
go workConnReaderFn(pxy.workConn, pxy.readCh)
go heartbeatFn(pxy.sendCh)
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
+2 -10
View File
@@ -57,7 +57,8 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
defer conn.Close()
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
var natHoleSidMsg msg.NatHoleSid
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
if err != nil {
xl.Errorf("xtcp read from workConn error: %v", err)
return
@@ -130,15 +131,6 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
}
func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
var natHoleSidMsg msg.NatHoleSid
if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
return nil, err
}
return &natHoleSidMsg, nil
}
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
listenConn.Close()
-66
View File
@@ -1,66 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package proxy
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
}{
{name: "v2", wireProtocol: wire.ProtocolV2},
{name: "v1", wireProtocol: wire.ProtocolV1},
{name: "default", wireProtocol: ""},
} {
t.Run(tc.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
}()
out, err := readNatHoleSid(client, tc.wireProtocol)
require.NoError(t, err)
require.Equal(t, "sid", out.Sid)
require.NoError(t, <-errCh)
})
}
}
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
t.Helper()
deadline := time.Now().Add(time.Second)
for _, conn := range conns {
require.NoError(t, conn.SetDeadline(deadline))
}
}
+90 -34
View File
@@ -21,8 +21,8 @@ import (
"net"
"net/http"
"os"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/fatedier/golib/crypto"
@@ -33,12 +33,12 @@ 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"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -111,9 +111,6 @@ func setServiceOptionsDefault(options *ServiceOptions) error {
// Service is the client service that connects to frps and provides proxy services.
type Service struct {
ctlMu sync.RWMutex
// Stores gracefulShutdownDuration independently from ctlMu, because the
// graceful shutdown wait may hold ctlMu for an arbitrary duration.
gracefulShutdownDuration atomic.Int64
// manager control connection with server
ctl *Control
// Uniq id got from frps, it will be attached to loginMsg.
@@ -154,7 +151,8 @@ type Service struct {
// service context
ctx context.Context
// call cancel to stop service
cancel context.CancelCauseFunc
cancel context.CancelCauseFunc
gracefulShutdownDuration time.Duration
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
@@ -264,7 +262,7 @@ func (svr *Service) Run(ctx context.Context) error {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
svr.stop()
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
}
go svr.keepControllerWorking()
@@ -305,41 +303,107 @@ func (svr *Service) keepControllerWorking() {
), true, svr.ctx.Done())
}
// login creates a connection to frps and registers it self as a client
// conn: control connection
// session: if it's not nil, using tcp mux
func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
xl := xlog.FromContextSafe(svr.ctx)
connector = svr.connectorCreator(svr.ctx, svr.common)
if err = connector.Open(); err != nil {
return nil, nil, err
}
defer func() {
if err != nil {
connector.Close()
}
}()
conn, err = connector.Connect()
if err != nil {
return
}
hostname, _ := os.Hostname()
loginMsg := &msg.Login{
Arch: runtime.GOARCH,
Os: runtime.GOOS,
Hostname: hostname,
PoolCount: svr.common.Transport.PoolCount,
User: svr.common.User,
ClientID: svr.common.ClientID,
Version: version.Full(),
Timestamp: time.Now().Unix(),
RunID: svr.runID,
Metas: svr.common.Metadatas,
}
if svr.clientSpec != nil {
loginMsg.ClientSpec = *svr.clientSpec
}
// Add auth
if err = svr.auth.Setter.SetLogin(loginMsg); err != nil {
return
}
if err = msg.WriteMsg(conn, loginMsg); err != nil {
return
}
var loginRespMsg msg.LoginResp
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
if err = msg.ReadMsgInto(conn, &loginRespMsg); err != nil {
return
}
_ = conn.SetReadDeadline(time.Time{})
if loginRespMsg.Error != "" {
err = fmt.Errorf("%s", loginRespMsg.Error)
xl.Errorf("%s", loginRespMsg.Error)
return
}
svr.runID = loginRespMsg.RunID
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
xl.Infof("login to server success, get run id [%s]", loginRespMsg.RunID)
return
}
func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {
xl := xlog.FromContextSafe(svr.ctx)
loginFunc := func() (bool, error) {
xl.Infof("尝试连接到服务器...")
dialer := &controlSessionDialer{
ctx: svr.ctx,
common: svr.common,
auth: svr.auth,
clientSpec: svr.clientSpec,
vnetController: svr.vnetController,
connectorCreator: svr.connectorCreator,
}
sessionCtx, err := dialer.Dial(svr.runID)
xl.Infof("try to connect to server...")
conn, connector, err := svr.login()
if err != nil {
xl.Warnf("连接服务器错误: %v", err)
xl.Warnf("connect to server error: %v", err)
if firstLoginExit {
svr.cancel(cancelErr{Err: err})
}
return false, err
}
svr.runID = sessionCtx.RunID
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
xl.Infof("login to server success, get run id [%s]", svr.runID)
svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
connEncrypted := svr.clientSpec == nil || svr.clientSpec.Type != "ssh-tunnel"
sessionCtx := &SessionContext{
Common: svr.common,
RunID: svr.runID,
Conn: conn,
ConnEncrypted: connEncrypted,
Auth: svr.auth,
Connector: connector,
VnetController: svr.vnetController,
}
ctl, err := NewControl(svr.ctx, sessionCtx)
if err != nil {
sessionCtx.Conn.Close()
sessionCtx.Connector.Close()
conn.Close()
xl.Errorf("new control error: %v", err)
return false, err
}
@@ -416,7 +480,7 @@ func (svr *Service) Close() {
}
func (svr *Service) GracefulClose(d time.Duration) {
svr.gracefulShutdownDuration.Store(int64(d))
svr.gracefulShutdownDuration = d
svr.cancel(nil)
}
@@ -433,8 +497,7 @@ func (svr *Service) stop() {
svr.ctlMu.Lock()
defer svr.ctlMu.Unlock()
if svr.ctl != nil {
d := time.Duration(svr.gracefulShutdownDuration.Load())
svr.ctl.GracefulClose(d)
svr.ctl.GracefulClose(svr.gracefulShutdownDuration)
svr.ctl = nil
}
if svr.webServer != nil {
@@ -511,13 +574,6 @@ 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 {
-95
View File
@@ -1,95 +0,0 @@
package client
import (
"context"
"net"
"sync"
"testing"
"time"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/client/visitor"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
)
type gracefulCloseTestConnector struct {
conn net.Conn
}
func (*gracefulCloseTestConnector) Connect() (*msg.Conn, error) { return nil, net.ErrClosed }
func (c *gracefulCloseTestConnector) Close() error { return c.conn.Close() }
func newGracefulCloseTestService() *Service {
ctx := context.Background()
common := &v1.ClientCommonConfig{}
serverConn, clientConn := net.Pipe()
ctl := &Control{
ctx: ctx,
sessionCtx: &SessionContext{
Common: common,
RunID: "graceful-close-race",
Conn: msg.NewConn(clientConn, msg.NewV1ReadWriter(clientConn)),
Connector: &gracefulCloseTestConnector{conn: serverConn},
},
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, "")
return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})}
}
func TestGracefulCloseAndStopSynchronizeDuration(t *testing.T) {
for i := range 10000 {
svr := newGracefulCloseTestService()
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
<-start
svr.GracefulClose(time.Duration(i))
}()
go func() {
defer wg.Done()
<-start
svr.stop()
}()
close(start)
wg.Wait()
}
}
func TestGracefulCloseDoesNotBlockDuringStop(t *testing.T) {
const gracefulDuration = 200 * time.Millisecond
svr := newGracefulCloseTestService()
svr.GracefulClose(gracefulDuration)
stopDone := make(chan struct{})
go func() {
svr.stop()
close(stopDone)
}()
defer func() {
select {
case <-stopDone:
case <-time.After(time.Second):
t.Error("stop did not finish")
}
}()
deadline := time.Now().Add(time.Second)
for svr.ctlMu.TryLock() {
svr.ctlMu.Unlock()
if time.Now().After(deadline) {
t.Fatal("stop did not acquire ctlMu")
}
time.Sleep(time.Millisecond)
}
start := time.Now()
svr.GracefulClose(0)
if elapsed := time.Since(start); elapsed >= gracefulDuration/2 {
t.Fatalf("GracefulClose blocked for %v while stop was waiting", elapsed)
}
}
+11 -18
View File
@@ -113,22 +113,15 @@ 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")
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)
closeCh := make(chan struct{})
// udp service -> frpc -> frps -> frpc visitor -> user
workConnReaderFn := func(payloadConn *msg.Conn) {
workConnReaderFn := func(conn net.Conn) {
defer func() {
payloadConn.Close()
conn.Close()
close(closeCh)
wg.Done()
}()
@@ -140,13 +133,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
)
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
return
}
_ = payloadConn.SetReadDeadline(time.Time{})
_ = conn.SetReadDeadline(time.Time{})
switch m := rawMsg.(type) {
case *msg.Ping:
xl.Debugf("frpc visitor get ping message from frpc")
@@ -164,15 +157,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
workConnSenderFn := func(payloadConn *msg.Conn) {
workConnSenderFn := func(conn net.Conn) {
defer func() {
payloadConn.Close()
conn.Close()
wg.Done()
}()
var errRet error
if firstPacket != nil {
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
@@ -187,7 +180,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
return
}
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
@@ -198,8 +191,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
}
go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn)
go workConnReaderFn(workConn)
go workConnSenderFn(workConn)
wg.Wait()
xl.Infof("sudp worker is closed")
+4 -15
View File
@@ -38,7 +38,7 @@ import (
// Helper wraps some functions for visitor to use.
type Helper interface {
// ConnectServer directly connects to the frp server.
ConnectServer() (*msg.Conn, error)
ConnectServer() (net.Conn, error)
// TransferConn transfers the connection to another visitor.
TransferConn(string, net.Conn) error
// MsgTransporter returns the message transporter that is used to send and receive messages
@@ -50,17 +50,6 @@ 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
@@ -178,15 +167,15 @@ func (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, e
UseEncryption: cfg.Transport.UseEncryption,
UseCompression: cfg.Transport.UseCompression,
}
err = visitorConn.WriteMsg(newVisitorConnMsg)
err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
if err != nil {
visitorConn.Close()
return nil, fmt.Errorf("send newVisitorConnMsg to server error: %v", err)
}
_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
var newVisitorConnRespMsg msg.NewVisitorConnResp
err = visitorConn.ReadMsgInto(&newVisitorConnRespMsg)
_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
if err != nil {
visitorConn.Close()
return nil, fmt.Errorf("read newVisitorConnRespMsg error: %v", err)
+3 -15
View File
@@ -25,7 +25,6 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -50,15 +49,10 @@ func NewManager(
ctx context.Context,
runID string,
clientCfg *v1.ClientCommonConfig,
connectServer func() (*msg.Conn, error),
connectServer func() (net.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),
@@ -73,7 +67,6 @@ func NewManager(
vnetController: vnetController,
transferConnFn: m.TransferConn,
runID: runID,
udpPacketCodec: udpPacketCodec,
}
return m
}
@@ -206,15 +199,14 @@ func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
}
type visitorHelperImpl struct {
connectServerFn func() (*msg.Conn, error)
connectServerFn func() (net.Conn, error)
msgTransporter transport.MessageTransporter
vnetController *vnet.Controller
transferConnFn func(name string, conn net.Conn) error
runID string
udpPacketCodec string
}
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
func (v *visitorHelperImpl) ConnectServer() (net.Conn, error) {
return v.connectServerFn()
}
@@ -233,7 +225,3 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller {
func (v *visitorHelperImpl) RunID() string {
return v.runID
}
func (v *visitorHelperImpl) UDPPacketCodec() string {
return v.udpPacketCodec
}
+1 -5
View File
@@ -54,11 +54,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
Use: name,
Short: short,
Run: func(cmd *cobra.Command, args []string) {
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
fmt.Println("frpc: the configuration file is not specified")
os.Exit(1)
}
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
fmt.Println(err)
os.Exit(1)
+2 -13
View File
@@ -48,19 +48,8 @@ var natholeDiscoveryCmd = &cobra.Command{
Short: "Discover nathole information from stun server",
RunE: func(cmd *cobra.Command, args []string) error {
// ignore error here, because we can use command line parameters
var cfg *v1.ClientCommonConfig
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
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, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
cfg = &v1.ClientCommonConfig{}
if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err)
+3 -5
View File
@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "")
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "")
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -141,13 +141,11 @@ func startService(
visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
nodeName string,
tunnelRemark string,
) error {
configSource := source.NewConfigSource()
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
return fmt.Errorf("failed to set config source: %w", err)
}
aggregator := source.NewAggregator(configSource)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
}
+15 -246
View File
@@ -16,11 +16,8 @@ package sub
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -36,29 +33,26 @@ 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 (
cfgFiles []string
cfgFile string
cfgDir string
showVersion bool
strictConfigMode bool
allowUnsafe []string
authTokens []string
bannerDisplayed bool
)
func init() {
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
rootCmd.PersistentFlags().StringSliceVarP(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
}
@@ -74,30 +68,15 @@ var rootCmd = &cobra.Command{
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
// If authTokens is provided, fetch config from API
if len(authTokens) > 0 {
err := runClientWithTokens(authTokens, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
return nil
}
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
// Note that it's only designed for testing. It's not guaranteed to be stable.
if cfgDir != "" {
_ = runMultipleClients(cfgDir, unsafeFeatures)
return nil
}
// If multiple config files are specified, run one frpc service for each file
if len(cfgFiles) > 1 {
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
return nil
}
// Do not show command usage here.
err := runClient(cfgFiles[0], unsafeFeatures)
err := runClient(cfgFile, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -127,29 +106,6 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
return err
}
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
var wg sync.WaitGroup
// Display banner first
banner.DisplayBanner()
bannerDisplayed = true
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
for _, cfgFile := range cfgFiles {
wg.Add(1)
// Add a small delay to avoid log output mixing
time.Sleep(100 * time.Millisecond)
go func(path string) {
defer wg.Done()
err := runClient(path, unsafeFeatures)
if err != nil {
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
}
}(cfgFile)
}
wg.Wait()
}
func Execute() {
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
if err := rootCmd.Execute(); err != nil {
@@ -175,6 +131,12 @@ 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)
}
@@ -224,7 +186,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
return err
}
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "")
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
}
func startServiceWithAggregator(
@@ -232,25 +194,12 @@ func startServiceWithAggregator(
aggregator *source.Aggregator,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
nodeName string,
tunnelRemark string,
) error {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
// Display banner only once before starting the first service
if !bannerDisplayed {
banner.DisplayBanner()
bannerDisplayed = true
}
// Display node information if available
if nodeName != "" {
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
}
if cfgFile != "" {
log.Infof("启动 frpc 服务 [%s]", cfgFile)
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
}
svr, err := client.NewService(client.ServiceOptions{
Common: cfg,
@@ -268,183 +217,3 @@ func startServiceWithAggregator(
}
return svr.Run(context.Background())
}
// APIResponse represents the response from LoliaFRP API
type APIResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Config string `json:"config"`
NodeName string `json:"node_name"`
TunnelRemark string `json:"tunnel_remark"`
} `json:"data"`
}
// TokenInfo stores parsed id and token from the -t parameter
type TokenInfo struct {
ID string
Token string
}
func runClientWithTokens(tokens []string, unsafeFeatures *security.UnsafeFeatures) error {
// Parse all tokens (format: id:token)
tokenInfos := make([]TokenInfo, 0, len(tokens))
for _, t := range tokens {
parts := strings.SplitN(t, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid token format '%s', expected 'id:token'", t)
}
tokenInfos = append(tokenInfos, TokenInfo{
ID: strings.TrimSpace(parts[0]),
Token: strings.TrimSpace(parts[1]),
})
}
// Group tokens by token value (same token can have multiple IDs)
tokenToIDs := make(map[string][]string)
for _, ti := range tokenInfos {
tokenToIDs[ti.Token] = append(tokenToIDs[ti.Token], ti.ID)
}
// If we have multiple different tokens, start one service for each token group
if len(tokenToIDs) > 1 {
return runMultipleClientsWithTokens(tokenToIDs, unsafeFeatures)
}
// Get the single token and all its IDs
var token string
var ids []string
for t, idList := range tokenToIDs {
token = t
ids = idList
break
}
return runClientWithTokenAndIDs(token, ids, unsafeFeatures)
}
func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *security.UnsafeFeatures) error {
// Get API server address from environment variable
apiServer := os.Getenv("LOLIA_API")
if apiServer == "" {
apiServer = "https://api.lolia.link"
}
// Build URL with query parameters
url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ","))
// URL is constructed from trusted source (environment variable or hardcoded)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create API request: %v", err)
}
// Carry client version in User-Agent so the API knows which frpc version is requesting
req.Header.Set("User-Agent", version.Full())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch config from API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return fmt.Errorf("failed to decode API response: %v", err)
}
if apiResp.Code != 200 {
return fmt.Errorf("API error: %s", apiResp.Msg)
}
// Decode base64 config
configBytes, err := base64.StdEncoding.DecodeString(apiResp.Data.Config)
if err != nil {
return fmt.Errorf("failed to decode base64 config: %v", err)
}
// Load config directly from bytes
return runClientWithConfig(configBytes, unsafeFeatures, apiResp.Data.NodeName, apiResp.Data.TunnelRemark)
}
func runMultipleClientsWithTokens(tokenToIDs map[string][]string, unsafeFeatures *security.UnsafeFeatures) error {
var wg sync.WaitGroup
// Display banner first
banner.DisplayBanner()
bannerDisplayed = true
log.Infof("检测到 %d 个不同的 token,将并行启动多个 frpc 服务实例", len(tokenToIDs))
index := 0
for token, ids := range tokenToIDs {
wg.Add(1)
currentIndex := index
currentToken := token
currentIDs := ids
totalCount := len(tokenToIDs)
// Add a small delay to avoid log output mixing
time.Sleep(100 * time.Millisecond)
go func() {
defer wg.Done()
maskedToken := currentToken
if len(maskedToken) > 6 {
maskedToken = maskedToken[:3] + "***" + maskedToken[len(maskedToken)-3:]
} else {
maskedToken = "***"
}
log.Infof("[%d/%d] 启动 token: %s (IDs: %v)", currentIndex+1, totalCount, maskedToken, currentIDs)
err := runClientWithTokenAndIDs(currentToken, currentIDs, unsafeFeatures)
if err != nil {
fmt.Printf("\nToken [%s] 启动失败: %v\n", maskedToken, err)
}
}()
index++
}
wg.Wait()
return nil
}
func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeatures, nodeName, tunnelRemark string) error {
// Render template first
renderedBytes, err := config.RenderWithTemplate(configBytes, config.GetValues())
if err != nil {
return fmt.Errorf("failed to render template: %v", err)
}
var allCfg v1.ClientConfig
if err := config.LoadConfigure(renderedBytes, &allCfg, strictConfigMode); err != nil {
return fmt.Errorf("failed to parse config: %v", err)
}
cfg := &allCfg.ClientCommonConfig
proxyCfgs := make([]v1.ProxyConfigurer, 0, len(allCfg.Proxies))
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
visitorCfgs := make([]v1.VisitorConfigurer, 0, len(allCfg.Visitors))
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
// Call Complete to fill in default values
if err := cfg.Complete(); err != nil {
return fmt.Errorf("failed to complete config: %v", err)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
if err != nil {
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, "", nodeName, tunnelRemark)
}
+7 -15
View File
@@ -29,30 +29,22 @@ 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 len(cfgFiles) == 0 || cfgFiles[0] == "" {
if cfgFile == "" {
fmt.Println("frpc: the configuration file is not specified")
return nil
}
cfgFile := cfgFiles[0]
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
-67
View File
@@ -1,67 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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)
})
}
}
-41
View File
@@ -103,10 +103,6 @@ transport.poolCount = 5
# supports tcp, kcp, quic, websocket and wss now, default is tcp
transport.protocol = "tcp"
# FRP wire protocol used inside the selected transport.
# supports v1 and v2, default is v1. v2 requires frps support and must be enabled explicitly.
# transport.wireProtocol = "v1"
# set client binding ip when connect server, default is empty.
# only when protocol = tcp or websocket, the value will be used.
transport.connectServerLocalIP = "0.0.0.0"
@@ -275,10 +271,6 @@ localIP = "127.0.0.1"
localPort = 8000
subdomain = "web02"
customDomains = ["web02.yourdomain.com"]
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
# on the same domain always takes precedence over the redirect.
# httpRedirect = true
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
# v1 or v2 or empty
transport.proxyProtocolVersion = "v2"
@@ -340,14 +332,6 @@ type = "https2http"
localAddr = "127.0.0.1:80"
crtPath = "./server.crt"
keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp"
@@ -360,14 +344,6 @@ type = "https2https"
localAddr = "127.0.0.1:443"
crtPath = "./server.crt"
keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp"
@@ -381,15 +357,6 @@ localAddr = "127.0.0.1:443"
hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp"
[[proxies]]
name = "plugin_http2https_redirect"
type = "http"
customDomains = ["test.yourdomain.com"]
[proxies.plugin]
type = "http2https_redirect"
# Optional. Defaults to 443. Set this if the HTTPS entry is exposed on a non-standard port.
# httpsPort = 443
[[proxies]]
name = "plugin_http2http"
type = "tcp"
@@ -409,14 +376,6 @@ type = "tls2raw"
localAddr = "127.0.0.1:80"
crtPath = "./server.crt"
keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
[[proxies]]
name = "secret_tcp"
-12
View File
@@ -40,12 +40,6 @@ transport.maxPoolCount = 5
# If negative, keep-alive probes are disabled.
# transport.tcpKeepalive = 7200
# proxyIdleTimeout specifies the maximum time in seconds that a proxied user connection
# can stay open without any traffic in either direction before frps closes it.
# It reclaims connections whose endpoints are still open at the TCP level but will never send data again.
# By default, this value is 0, which disables the idle timeout.
# transport.proxyIdleTimeout = 7200
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
transport.tls.force = false
@@ -58,12 +52,6 @@ transport.tls.force = false
vhostHTTPPort = 80
vhostHTTPSPort = 443
# Port used in the Location header when redirecting HTTP requests to HTTPS for
# proxies with httpRedirect enabled. Set it when browsers reach the HTTPS vhost
# through a port mapping, so it differs from vhostHTTPSPort.
# By default, this value is vhostHTTPSPort.
# vhostHTTPSRedirectPort = 443
# Response header timeout(seconds) for vhost http server, default is 60s
# vhostHTTPTimeout = 60
+3 -47
View File
@@ -33,51 +33,7 @@ git commit -m "bump version to vX.Y.Z"
git push origin dev
```
## 3. Pre-release Validation
Run the standard e2e suite locally:
```bash
make e2e
```
For releases that touch compatibility-sensitive areas such as login, control
connections, work connections, visitors, transport, or wire protocol handling,
also run the manual compatibility e2e suite:
```bash
make e2e-compatibility
make e2e-compatibility-floor
```
`make e2e-compatibility` builds the current `frps` and `frpc`, resolves the
recent stable release baselines from GitHub, downloads or reuses their binaries,
and tests current binaries against those historical releases. The default number
of recent baselines is controlled by `FRP_COMPAT_BASELINE_COUNT` in the
`Makefile`.
Downloaded release binaries are cached under:
```text
.cache/e2e-compat/<version>/<os>_<arch>/
```
For a release validation run that must be exactly reproducible, pass an explicit
baseline matrix instead of using the floating recent-release list:
```bash
FRP_COMPAT_BASELINE_VERSIONS="0.X.0 0.Y.0" make e2e-compatibility
```
Use `make e2e-compatibility-smoke` for a quick single-baseline check while
iterating locally. If GitHub release metadata requests are rate-limited, set
`GITHUB_TOKEN` or use `FRP_COMPAT_BASELINE_VERSIONS`.
The compatibility floor is a support-policy decision, not a value that should
change every release. Update `FRP_COMPAT_FLOOR_VERSION` only when the declared
compatibility window changes.
## 4. Merge dev → master
## 3. Merge dev → master
Create a PR from `dev` to `master`:
@@ -87,7 +43,7 @@ gh pr create --base master --head dev --title "bump version"
Wait for CI to pass, then merge using **merge commit** (not squash).
## 5. Tag the Release
## 4. Tag the Release
```bash
git checkout master
@@ -96,7 +52,7 @@ git tag -a vX.Y.Z -m "bump version"
git push origin vX.Y.Z
```
## 6. Trigger GoReleaser
## 5. Trigger GoReleaser
Manually trigger the `goreleaser` workflow in GitHub Actions:
-38
View File
@@ -1,38 +0,0 @@
# Deprecations
This document tracks deprecated features and APIs that are still shipped but scheduled for removal. Maintainers should review this list before each release to decide whether any items are due for removal.
For the version compatibility policy that bounds these support windows, see the latest `Release.md`.
## Active
### Wire protocol v1
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
- **Removal target:** v0.78.0 or later. v0.69.0 (the last release where v1 is the default) is supported until v0.78.0 is released, so v0.77.0 is the last release that must keep v1 support.
- **Replacement:** wire protocol v2 (`transport.wireProtocol = "v2"` in frpc).
- **Code references:** v1 message types and codec under `pkg/msg/` and the protocol negotiation path in `client/` and `server/`.
- **Notes:** Removing v1 will also drop compatibility with any frpc/frps that does not negotiate v2.
### INI configuration format
- **Deprecated since:** predates this document; startup warning has been in place for several releases.
- **Removal target:** TBD.
- **Replacement:** YAML / JSON / TOML.
- **Code references:**
- `cmd/frpc/sub/root.go` — frpc startup warning.
- `cmd/frps/root.go` — frps startup warning.
- `pkg/config/legacy/` — legacy INI parser; remove together with the warnings.
### Visitor connections without `runID`
- **Deprecated since:** v0.50.0 (when `runID` was introduced).
- **Removal target:** TBD.
- **Replacement:** require `runID` on every visitor connection.
- **Code references:**
- `server/service.go` — `RegisterVisitorConn` still accepts empty `runID` for backward compatibility.
- **Notes:** Removal will break frpc clients released before v0.50.0. Schedule for a release where dropping pre-v0.50.0 frpc is acceptable.
## Removed
_None yet._
+19 -31
View File
@@ -4,10 +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/coreos/go-oidc/v3 v3.14.1
github.com/fatedier/golib v0.6.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
@@ -15,9 +13,10 @@ 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.15.0
github.com/pion/stun/v3 v3.1.1
github.com/pires/go-proxyproto v0.7.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.60.0
github.com/quic-go/quic-go v0.55.0
github.com/rodaine/table v1.2.0
github.com/samber/lo v1.47.0
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
@@ -27,31 +26,23 @@ require (
github.com/tidwall/gjson v1.17.1
github.com/vishvananda/netlink v1.3.0
github.com/xtaci/kcp-go/v5 v5.6.13
golang.org/x/crypto v0.54.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.20.0
golang.org/x/time v0.10.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
gopkg.in/ini.v1 v1.67.0
k8s.io/apimachinery v0.28.8
k8s.io/client-go v0.28.8
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
)
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-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
@@ -60,37 +51,34 @@ 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/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // 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
github.com/wlynxg/anet v0.0.5 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
// Use the Lolia-FRP fork of golib: io.Join relays with adaptively sized buffers.
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.0.0-20260925150110-6ae939472a09
+40 -59
View File
@@ -2,33 +2,17 @@ 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-20260925150110-6ae939472a09 h1:8WZ95iC7N28U1QqrRnJBrXQZUgsvb+i4RYx0xTesodo=
github.com/Lolia-FRP/golib v0.0.0-20260925150110-6ae939472a09/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=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -36,12 +20,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/golib v0.6.0 h1:/mgBZZbkbMhIEZoXf7nV8knpUDzas/b+2ruYKxx1lww=
github.com/fatedier/golib v0.6.0/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-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -86,23 +70,24 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
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.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
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/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=
@@ -118,13 +103,10 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
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/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -164,8 +146,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/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
@@ -177,46 +159,45 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -224,8 +205,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
-162
View File
@@ -1,162 +0,0 @@
#!/bin/sh
set -eu
SCRIPT=$(readlink -f "$0")
ROOT=$(unset CDPATH && cd "$(dirname "$SCRIPT")/.." && pwd)
if ! command -v ginkgo >/dev/null 2>&1; then
echo "ginkgo not found, try to install..."
go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4
fi
debug=false
if [ "x${DEBUG:-}" = "xtrue" ]; then
debug=true
fi
logLevel=debug
if [ "${LOG_LEVEL:-}" ]; then
logLevel="${LOG_LEVEL}"
fi
currentFrpsPath=${CURRENT_FRPS_PATH:-${ROOT}/bin/frps}
currentFrpcPath=${CURRENT_FRPC_PATH:-${ROOT}/bin/frpc}
baselineCount=${FRP_COMPAT_BASELINE_COUNT:-8}
targetOS=${TARGET_OS:-$(go env GOOS)}
targetArch=${TARGET_ARCH:-$(go env GOARCH)}
targetPlatform="${targetOS}_${targetArch}"
cacheRoot=${FRP_COMPAT_CACHE_DIR:-${ROOT}/.cache/e2e-compat}
check_file() {
if [ ! -f "$2" ]; then
echo "$1 not found: $2"
exit 1
fi
}
check_file "current frps" "${currentFrpsPath}"
check_file "current frpc" "${currentFrpcPath}"
run_current_current=true
run_compatibility() {
baselineVersion=$1
baselineFrpsPath=$2
baselineFrpcPath=$3
check_file "baseline frps" "${baselineFrpsPath}"
check_file "baseline frpc" "${baselineFrpcPath}"
echo "Running compatibility e2e with baseline ${baselineVersion}"
ginkgo -nodes=1 --poll-progress-after=60s "${ROOT}/test/e2e/compatibility" -- \
-current-frps-path="${currentFrpsPath}" \
-current-frpc-path="${currentFrpcPath}" \
-baseline-frps-path="${baselineFrpsPath}" \
-baseline-frpc-path="${baselineFrpcPath}" \
-baseline-version="${baselineVersion}" \
-run-current-current="${run_current_current}" \
-log-level="${logLevel}" \
-debug="${debug}"
run_current_current=false
}
github_api_curl() {
if [ "${GITHUB_TOKEN:-}" ]; then
curl -fsSL \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
"$1"
else
curl -fsSL "$1"
fi
}
resolve_versions() {
if [ "${FRP_COMPAT_BASELINE_VERSIONS:-}" ]; then
printf "%s\n" "${FRP_COMPAT_BASELINE_VERSIONS}"
return
fi
case "${baselineCount}" in
'' | *[!0-9]*)
echo "FRP_COMPAT_BASELINE_COUNT must be a positive integer: ${baselineCount}" >&2
exit 1
;;
esac
if [ "${baselineCount}" -eq 0 ]; then
echo "FRP_COMPAT_BASELINE_COUNT must be greater than 0" >&2
exit 1
fi
if [ "${baselineCount}" -gt 100 ]; then
echo "FRP_COMPAT_BASELINE_COUNT must be less than or equal to 100" >&2
exit 1
fi
releaseURL="https://api.github.com/repos/fatedier/frp/releases?per_page=100"
resolvedVersions=""
if releases=$(github_api_curl "${releaseURL}" 2>/dev/null); then
resolvedVersions=$(printf "%s\n" "${releases}" |
sed -n 's/.*"tag_name":[[:space:]]*"v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' |
awk '!seen[$0]++' |
head -n "${baselineCount}" |
tr '\n' ' ' |
sed 's/[[:space:]]*$//')
else
echo "Failed to fetch release metadata from GitHub API, falling back to GitHub releases page." >&2
fi
if [ -z "${resolvedVersions}" ]; then
releasesPageURL="https://github.com/fatedier/frp/releases"
if ! releases=$(curl -fsSL "${releasesPageURL}"); then
echo "Failed to fetch release metadata from GitHub: ${releasesPageURL}" >&2
echo "Set FRP_COMPAT_BASELINE_VERSIONS to run with explicit baseline versions." >&2
exit 1
fi
resolvedVersions=$(printf "%s\n" "${releases}" |
grep -o 'releases/tag/v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"' |
sed 's#.*/v##; s/"$//' |
awk '!seen[$0]++' |
head -n "${baselineCount}" |
tr '\n' ' ' |
sed 's/[[:space:]]*$//')
fi
set -- ${resolvedVersions}
if [ "$#" -lt "${baselineCount}" ]; then
echo "Only resolved $# stable release versions from GitHub, expected ${baselineCount}." >&2
echo "Set FRP_COMPAT_BASELINE_VERSIONS to run with explicit baseline versions." >&2
exit 1
fi
printf "%s\n" "${resolvedVersions}"
}
if [ "${BASELINE_FRPS_PATH:-}" ] || [ "${BASELINE_FRPC_PATH:-}" ]; then
if [ -z "${BASELINE_FRPS_PATH:-}" ] || [ -z "${BASELINE_FRPC_PATH:-}" ]; then
echo "BASELINE_FRPS_PATH and BASELINE_FRPC_PATH must be set together"
exit 1
fi
run_compatibility "${FRP_COMPAT_BASELINE_VERSION:-custom}" "${BASELINE_FRPS_PATH}" "${BASELINE_FRPC_PATH}"
exit 0
fi
versions=$(resolve_versions)
echo "Compatibility baseline versions: ${versions}"
mkdir -p "${cacheRoot}"
for version in ${versions}; do
baselineDir="${cacheRoot}/${version}/${targetPlatform}"
if [ ! -f "${baselineDir}/frps" ] || [ ! -f "${baselineDir}/frpc" ]; then
tmpDir="${cacheRoot}/.download-${version}-${targetPlatform}"
rm -rf "${tmpDir}"
(
cd "${cacheRoot}"
FRP_VERSION="${version}" TARGET_DIRNAME="$(basename "${tmpDir}")" "${ROOT}/hack/download.sh"
)
mkdir -p "$(dirname "${baselineDir}")"
rm -rf "${baselineDir}"
mv "${tmpDir}" "${baselineDir}"
fi
run_compatibility "${version}" "${baselineDir}/frps" "${baselineDir}/frpc"
done
+1 -1
View File
@@ -18,7 +18,7 @@ rm -rf ./release/packages
mkdir -p ./release/packages
os_all='linux windows darwin freebsd openbsd android'
arch_all='amd64 arm arm64'
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
extra_all='_ hf'
cd ./release
+1 -1
View File
@@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
}
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
-29
View File
@@ -394,10 +394,6 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
}
}
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
return nil, err
}
return result, nil
}
@@ -421,31 +417,6 @@ func LoadClientConfig(path string, strict bool) (
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
}
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
// keyed by name in the config sources, so a duplicate would otherwise be silently
// overwritten and never started, with no error or log.
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
proxyNames := make(map[string]struct{}, len(proxies))
for _, p := range proxies {
name := p.GetBaseConfig().Name
if _, ok := proxyNames[name]; ok {
return fmt.Errorf("proxy name [%s] is duplicated", name)
}
proxyNames[name] = struct{}{}
}
visitorNames := make(map[string]struct{}, len(visitors))
for _, v := range visitors {
name := v.GetBaseConfig().Name
if _, ok := visitorNames[name]; ok {
return fmt.Errorf("visitor name [%s] is duplicated", name)
}
visitorNames[name] = struct{}{}
}
return nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies
for _, c := range proxyCfgs {
-107
View File
@@ -17,8 +17,6 @@ package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -464,111 +462,6 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
require.Equal("keep", proxies[0].GetBaseConfig().Name)
}
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
tests := []struct {
name string
content string
errSubstr string
}{
{
name: "duplicate proxy names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
errSubstr: "proxy name [dup] is duplicated",
},
{
name: "duplicate visitor names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[visitors]]
name = "dup"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
[[visitors]]
name = "dup"
type = "stcp"
serverName = "b"
secretKey = "secret"
bindPort = 9002
`,
errSubstr: "visitor name [dup] is duplicated",
},
{
name: "unique names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "p1"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "p2"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
},
{
name: "same name across proxy and visitor",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "same"
type = "tcp"
localPort = 22
remotePort = 6000
[[visitors]]
name = "same"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
_, err := LoadClientConfigResult(path, false)
if tc.errSubstr == "" {
require.NoError(err)
} else {
require.ErrorContains(err, tc.errSubstr)
}
})
}
}
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
func TestYAMLEdgeCases(t *testing.T) {
require := require.New(t)
+17 -7
View File
@@ -14,7 +14,11 @@
package source
import v1 "github.com/fatedier/frp/pkg/config/v1"
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// ConfigSource implements Source for in-memory configuration.
// All operations are thread-safe.
@@ -35,17 +39,23 @@ func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.Vi
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
for _, p := range proxies {
name, err := validateProxyName(p)
if err != nil {
return err
if p == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := p.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
nextProxies[name] = p
}
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
for _, v := range visitors {
name, err := validateVisitorName(v)
if err != nil {
return err
if v == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := v.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
nextVisitors[name] = v
}
+126 -108
View File
@@ -43,11 +43,6 @@ var (
ErrNotFound = errors.New("not found")
)
const (
storeKindProxy = "proxy"
storeKindVisitor = "visitor"
)
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("path is required")
@@ -177,111 +172,79 @@ func (s *StoreSource) saveToFileUnlocked() error {
return nil
}
func (s *StoreSource) persistOrRollbackUnlocked(rollback func()) error {
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.proxies[name]; exists {
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
rollback()
delete(s.proxies, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
// Store map selectors return the target map for generic helpers.
func proxyStoreEntries(s *StoreSource) map[string]v1.ProxyConfigurer {
return s.proxies
}
func visitorStoreEntries(s *StoreSource) map[string]v1.VisitorConfigurer {
return s.visitors
}
// Store entry helpers share mutation, persistence, and rollback for proxy and visitor maps.
// T is intentionally limited by callers to v1.ProxyConfigurer or v1.VisitorConfigurer.
func addStoreEntry[T any](
s *StoreSource,
entriesFn func(*StoreSource) map[string]T,
kind string,
name string,
value T,
) error {
s.mu.Lock()
defer s.mu.Unlock()
entries := entriesFn(s)
if _, exists := entries[name]; exists {
return fmt.Errorf("%w: %s %q", ErrAlreadyExists, kind, name)
}
entries[name] = value
return s.persistOrRollbackUnlocked(func() {
delete(entries, name)
})
}
func updateStoreEntry[T any](
s *StoreSource,
entriesFn func(*StoreSource) map[string]T,
kind string,
name string,
value T,
) error {
s.mu.Lock()
defer s.mu.Unlock()
entries := entriesFn(s)
old, exists := entries[name]
if !exists {
return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
}
entries[name] = value
return s.persistOrRollbackUnlocked(func() {
entries[name] = old
})
}
func removeStoreEntry[T any](
s *StoreSource,
entriesFn func(*StoreSource) map[string]T,
kind string,
name string,
) error {
if name == "" {
return fmt.Errorf("%s name cannot be empty", kind)
}
s.mu.Lock()
defer s.mu.Unlock()
entries := entriesFn(s)
old, exists := entries[name]
if !exists {
return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
}
delete(entries, name)
return s.persistOrRollbackUnlocked(func() {
entries[name] = old
})
}
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
name, err := validateProxyName(proxy)
if err != nil {
return err
}
return addStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
}
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
name, err := validateProxyName(proxy)
if err != nil {
return err
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
return updateStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveProxy(name string) error {
return removeStoreEntry(s, proxyStoreEntries, storeKindProxy, name)
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
}
delete(s.proxies, name)
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
@@ -296,23 +259,78 @@ func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
}
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
name, err := validateVisitorName(visitor)
if err != nil {
return err
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
return addStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.visitors[name]; exists {
return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
delete(s.visitors, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
name, err := validateVisitorName(visitor)
if err != nil {
return err
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
return updateStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveVisitor(name string) error {
return removeStoreEntry(s, visitorStoreEntries, storeKindVisitor, name)
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
}
delete(s.visitors, name)
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
-96
View File
@@ -17,7 +17,6 @@ package source
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
@@ -60,101 +59,6 @@ func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}
func TestStoreSource_UpdateAndRemoveProxyAndVisitor(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
proxyCfg := mockProxy("proxy1")
visitorCfg := mockVisitor("visitor1")
require.NoError(storeSource.AddProxy(proxyCfg))
require.NoError(storeSource.AddVisitor(visitorCfg))
require.ErrorIs(storeSource.AddProxy(proxyCfg), ErrAlreadyExists)
require.ErrorIs(storeSource.AddVisitor(visitorCfg), ErrAlreadyExists)
require.ErrorContains(storeSource.RemoveProxy(""), "proxy name cannot be empty")
require.ErrorContains(storeSource.RemoveVisitor(""), "visitor name cannot be empty")
updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
updatedProxy.RemotePort = 19090
require.NoError(storeSource.UpdateProxy(updatedProxy))
require.Equal(19090, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
updatedVisitor.ServerName = "updated-server"
require.NoError(storeSource.UpdateVisitor(updatedVisitor))
require.Equal("updated-server", storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
require.NoError(storeSource.RemoveProxy("proxy1"))
require.Nil(storeSource.GetProxy("proxy1"))
require.ErrorIs(storeSource.RemoveProxy("proxy1"), ErrNotFound)
require.NoError(storeSource.RemoveVisitor("visitor1"))
require.Nil(storeSource.GetVisitor("visitor1"))
require.ErrorIs(storeSource.RemoveVisitor("visitor1"), ErrNotFound)
require.ErrorIs(storeSource.UpdateProxy(updatedProxy), ErrNotFound)
require.ErrorIs(storeSource.UpdateVisitor(updatedVisitor), ErrNotFound)
}
func TestStoreSource_MutationRollsBackOnPersistFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod does not make directories unwritable on Windows")
}
if os.Getuid() == 0 {
t.Skip("chmod does not block writes for uid 0")
}
require := require.New(t)
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
proxyCfg := mockProxy("proxy1")
visitorCfg := mockVisitor("visitor1")
originalRemotePort := proxyCfg.(*v1.TCPProxyConfig).RemotePort
originalServerName := visitorCfg.(*v1.STCPVisitorConfig).ServerName
require.NoError(storeSource.AddProxy(proxyCfg))
require.NoError(storeSource.AddVisitor(visitorCfg))
require.NoError(os.Chmod(dir, 0o500))
t.Cleanup(func() {
_ = os.Chmod(dir, 0o700)
})
requirePersistError := func(err error) {
t.Helper()
require.Error(err)
require.ErrorContains(err, "failed to persist")
require.NotErrorIs(err, ErrAlreadyExists)
require.NotErrorIs(err, ErrNotFound)
}
requirePersistError(storeSource.AddProxy(mockProxy("proxy2")))
require.Nil(storeSource.GetProxy("proxy2"))
updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
updatedProxy.RemotePort = 19090
requirePersistError(storeSource.UpdateProxy(updatedProxy))
require.Equal(originalRemotePort, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
requirePersistError(storeSource.RemoveProxy("proxy1"))
require.NotNil(storeSource.GetProxy("proxy1"))
requirePersistError(storeSource.AddVisitor(mockVisitor("visitor2")))
require.Nil(storeSource.GetVisitor("visitor2"))
updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
updatedVisitor.ServerName = "updated-server"
requirePersistError(storeSource.UpdateVisitor(updatedVisitor))
require.Equal(originalServerName, storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
requirePersistError(storeSource.RemoveVisitor("visitor1"))
require.NotNil(storeSource.GetVisitor("visitor1"))
}
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
-43
View File
@@ -1,43 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func validateProxyName(proxy v1.ProxyConfigurer) (string, error) {
if proxy == nil {
return "", fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return "", fmt.Errorf("proxy name cannot be empty")
}
return name, nil
}
func validateVisitorName(visitor v1.VisitorConfigurer) (string, error) {
if visitor == nil {
return "", fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return "", fmt.Errorf("visitor name cannot be empty")
}
return name, nil
}
+1 -6
View File
@@ -49,8 +49,7 @@ type ClientCommonConfig struct {
// STUN server to help penetrate NAT hole.
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
// DNSServer specifies a DNS server address for FRPC to use. If this value
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
// supported, e.g. "https://1.1.1.1/dns-query".
// is "", the default DNS will be used.
DNSServer string `json:"dnsServer,omitempty"`
// LoginFailExit controls whether or not the client should exit after a
// failed login attempt. If false, the client will retry until a login
@@ -105,9 +104,6 @@ type ClientTransportConfig struct {
// Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value
// is "tcp".
Protocol string `json:"protocol,omitempty"`
// WireProtocol specifies the frpc/frps internal wire protocol version.
// Valid values are "v1" and "v2". By default, this value is "v1".
WireProtocol string `json:"wireProtocol,omitempty"`
// The maximum amount of time a dial to server will wait for a connect to complete.
DialServerTimeout int64 `json:"dialServerTimeout,omitempty"`
// DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.
@@ -147,7 +143,6 @@ type ClientTransportConfig struct {
func (c *ClientTransportConfig) Complete() {
c.Protocol = util.EmptyOr(c.Protocol, "tcp")
c.WireProtocol = util.EmptyOr(c.WireProtocol, "v1")
c.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10)
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
-1
View File
@@ -29,7 +29,6 @@ func TestClientConfigComplete(t *testing.T) {
require.EqualValues("token", c.Auth.Method)
require.Equal(true, lo.FromPtr(c.Transport.TCPMux))
require.Equal("v1", c.Transport.WireProtocol)
require.Equal(true, lo.FromPtr(c.LoginFailExit))
require.Equal(true, lo.FromPtr(c.Transport.TLS.Enable))
require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))
-8
View File
@@ -369,12 +369,6 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
ProxyBaseConfig
DomainConfig
// HTTPRedirect requests frps to redirect plain HTTP requests for the
// proxy's domains to their HTTPS endpoint. It only takes effect when
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
// proxy registered on the same domain.
HTTPRedirect bool `json:"httpRedirect,omitempty"`
}
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
@@ -382,7 +376,6 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
m.CustomDomains = c.CustomDomains
m.SubDomain = c.SubDomain
m.HTTPRedirect = c.HTTPRedirect
}
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
@@ -390,7 +383,6 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.CustomDomains = m.CustomDomains
c.SubDomain = m.SubDomain
c.HTTPRedirect = m.HTTPRedirect
}
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
+24 -71
View File
@@ -16,7 +16,6 @@ package v1
import (
"reflect"
"slices"
"github.com/samber/lo"
@@ -25,31 +24,29 @@ import (
)
const (
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"
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"
)
var clientPluginOptionsTypeMap = map[string]reflect.Type{
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
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](),
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 {
@@ -83,29 +80,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.ClientPluginOptions)
}
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
// that terminate TLS locally.
type AutoTLSOptions struct {
Enable bool `json:"enable,omitempty"`
// Contact email for certificate expiration and important notices.
Email string `json:"email,omitempty"`
// Directory used to cache ACME account and certificates.
CacheDir string `json:"cacheDir,omitempty"`
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
CADirURL string `json:"caDirURL,omitempty"`
// Restrict certificate issuance to the listed domains.
HostAllowList []string `json:"hostAllowList,omitempty"`
}
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
if o == nil {
return nil
}
out := *o
out.HostAllowList = slices.Clone(o.HostAllowList)
return &out
}
type HTTP2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -124,21 +98,6 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
return &out
}
type HTTP2HTTPSRedirectPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPSPort int `json:"httpsPort,omitempty"`
}
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"`
@@ -163,7 +122,6 @@ 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() {
@@ -177,7 +135,6 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
@@ -189,7 +146,6 @@ 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() {
@@ -203,7 +159,6 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
@@ -275,11 +230,10 @@ 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"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
}
func (o *TLS2RawPluginOptions) Complete() {}
@@ -289,7 +243,6 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
return nil
}
out := *o
out.AutoTLS = o.AutoTLS.Clone()
return &out
}
+1 -14
View File
@@ -51,12 +51,6 @@ type ServerConfig struct {
// Vhost requests. If this value is 0, the server will not listen for HTTPS
// requests.
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
// VhostHTTPSRedirectPort specifies the port used in the Location header
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
// enabled. Set it when browsers reach the HTTPS vhost through a port
// mapping, so it differs from VhostHTTPSPort. By default, this value is
// VhostHTTPSPort.
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
// requests on one single port. If it's not - it will listen on this value for
@@ -124,7 +118,6 @@ 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)
@@ -174,18 +167,12 @@ 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. Negative values are invalid.
// this value is 5.
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.
-43
View File
@@ -1,43 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
)
func (v *ConfigValidator) validateAuthTokenSource(token string, tokenSource *v1.ValueSource) error {
var errs error
// Preserve the previous client/server validation order for joined errors.
if token != "" && tokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
if tokenSource == nil {
return errs
}
if tokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := tokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
return errs
}
-228
View File
@@ -1,228 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
)
const (
tokenSourceConflictErr = "cannot specify both auth.token and auth.tokenSource"
tokenSourceExecErr = "unsafe feature \"TokenSourceExec\" is not enabled. To enable it, ensure it is allowed in the configuration or command line flags"
invalidFileSourceErr = "invalid auth.tokenSource: file configuration is required when type is 'file'"
unsupportedSourceErr = "invalid auth.tokenSource: unsupported value source type: env (only 'file' and 'exec' are supported)"
)
func TestValidateAuthTokenSource(t *testing.T) {
for _, tc := range authTokenSourceTestCases() {
t.Run(tc.name, func(t *testing.T) {
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
err := validator.validateAuthTokenSource(tc.token, tc.tokenSource())
requireValidationErrors(t, err, tc.wantErrs)
})
}
}
func TestValidateClientAuthTokenSource(t *testing.T) {
for _, tc := range authTokenSourceTestCases() {
t.Run(tc.name, func(t *testing.T) {
auth := v1.AuthClientConfig{
Method: v1.AuthMethodToken,
Token: tc.token,
TokenSource: tc.tokenSource(),
}
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
_, err := validator.ValidateClientCommonConfig(validClientConfigWithAuth(auth))
requireValidationErrors(t, err, tc.wantErrs)
})
}
}
func TestValidateServerAuthTokenSource(t *testing.T) {
for _, tc := range authTokenSourceTestCases() {
t.Run(tc.name, func(t *testing.T) {
auth := v1.AuthServerConfig{
Method: v1.AuthMethodToken,
Token: tc.token,
TokenSource: tc.tokenSource(),
}
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
_, err := validator.ValidateServerConfig(validServerConfigWithAuth(auth))
requireValidationErrors(t, err, tc.wantErrs)
})
}
}
type authTokenSourceTestCase struct {
name string
token string
tokenSource func() *v1.ValueSource
unsafeAllowed bool
wantErrs []string
}
func authTokenSourceTestCases() []authTokenSourceTestCase {
return []authTokenSourceTestCase{
{
name: "empty token config",
tokenSource: nilTokenSource,
},
{
name: "valid file tokenSource",
tokenSource: validFileTokenSource,
},
{
name: "literal token without tokenSource",
token: "token",
tokenSource: nilTokenSource,
},
{
name: "literal token conflicts with file tokenSource",
token: "token",
tokenSource: validFileTokenSource,
wantErrs: []string{tokenSourceConflictErr},
},
{
name: "exec tokenSource requires unsafe feature",
tokenSource: validExecTokenSource,
wantErrs: []string{tokenSourceExecErr},
},
{
name: "exec tokenSource with unsafe feature allowed",
tokenSource: validExecTokenSource,
unsafeAllowed: true,
},
{
name: "literal token conflicts with exec tokenSource and unsafe feature disabled",
token: "token",
tokenSource: validExecTokenSource,
wantErrs: []string{
tokenSourceConflictErr,
tokenSourceExecErr,
},
},
{
name: "literal token conflicts with exec tokenSource and unsafe feature allowed",
token: "token",
tokenSource: validExecTokenSource,
unsafeAllowed: true,
wantErrs: []string{tokenSourceConflictErr},
},
{
name: "invalid file tokenSource is wrapped",
tokenSource: invalidFileTokenSource,
wantErrs: []string{invalidFileSourceErr},
},
{
name: "unsupported tokenSource type is wrapped",
tokenSource: unsupportedTokenSource,
wantErrs: []string{unsupportedSourceErr},
},
}
}
func newAuthTokenSourceValidator(unsafeAllowed bool) *ConfigValidator {
if !unsafeAllowed {
return NewConfigValidator(nil)
}
return NewConfigValidator(security.NewUnsafeFeatures([]string{security.TokenSourceExec}))
}
func requireValidationErrors(t *testing.T, err error, wantErrs []string) {
t.Helper()
if len(wantErrs) == 0 {
require.NoError(t, err)
return
}
require.Error(t, err)
// Client/server validators may wrap joined errors in another join layer; compare leaf errors.
gotErrs := unwrapValidationErrors(err)
require.Len(t, gotErrs, len(wantErrs))
for i, wantErr := range wantErrs {
require.EqualError(t, gotErrs[i], wantErr)
}
}
func unwrapValidationErrors(err error) []error {
type joinedError interface {
Unwrap() []error
}
joined, ok := err.(joinedError)
if !ok {
return []error{err}
}
var errs []error
for _, err := range joined.Unwrap() {
errs = append(errs, unwrapValidationErrors(err)...)
}
return errs
}
// nilTokenSource keeps the shared table shape uniform for cases without a tokenSource.
func nilTokenSource() *v1.ValueSource {
return nil
}
func validFileTokenSource() *v1.ValueSource {
return &v1.ValueSource{
Type: "file",
File: &v1.FileSource{Path: "token.txt"},
}
}
func validExecTokenSource() *v1.ValueSource {
return &v1.ValueSource{
Type: "exec",
Exec: &v1.ExecSource{Command: "print-token"},
}
}
func invalidFileTokenSource() *v1.ValueSource {
return &v1.ValueSource{
Type: "file",
}
}
func unsupportedTokenSource() *v1.ValueSource {
return &v1.ValueSource{Type: "env"}
}
func validClientConfigWithAuth(auth v1.AuthClientConfig) *v1.ClientCommonConfig {
return &v1.ClientCommonConfig{
Auth: auth,
Log: v1.LogConfig{
Level: "info",
},
Transport: v1.ClientTransportConfig{
Protocol: "tcp",
WireProtocol: "v1",
},
}
}
func validServerConfigWithAuth(auth v1.AuthServerConfig) *v1.ServerConfig {
return &v1.ServerConfig{
Auth: auth,
Log: v1.LogConfig{
Level: "info",
},
}
}
+17 -42
View File
@@ -51,51 +51,14 @@ 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 !gates.Enabled(featuregate.VirtualNet) {
if !featuregate.Enabled(featuregate.VirtualNet) {
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
}
return nil, nil
}
// 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) {
@@ -105,7 +68,22 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
errs = AppendError(errs, v.validateAuthTokenSource(c.Token, c.TokenSource))
// Validate token/tokenSource mutual exclusivity
if c.Token != "" && c.TokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
// Validate tokenSource if specified
if c.TokenSource != nil {
if c.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
}
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
@@ -168,9 +146,6 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
}
if !slices.Contains(SupportedWireProtocols, c.WireProtocol) {
errs = AppendError(errs, fmt.Errorf("invalid transport.wireProtocol, optional values are %v", SupportedWireProtocols))
}
return warnings, errs
}
-140
View File
@@ -1,140 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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,8 +16,6 @@ package validation
import (
"errors"
"fmt"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -26,8 +24,6 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
switch v := c.(type) {
case *v1.HTTP2HTTPSPluginOptions:
return validateHTTP2HTTPSPluginOptions(v)
case *v1.HTTP2HTTPSRedirectPluginOptions:
return validateHTTP2HTTPSRedirectPluginOptions(v)
case *v1.HTTPS2HTTPPluginOptions:
return validateHTTPS2HTTPPluginOptions(v)
case *v1.HTTPS2HTTPSPluginOptions:
@@ -49,17 +45,10 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
return nil
}
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
return ValidatePort(c.HTTPSPort, "httpsPort")
}
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
if c.LocalAddr == "" {
return errors.New("localAddr is required")
}
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
@@ -67,9 +56,6 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
if c.LocalAddr == "" {
return errors.New("localAddr is required")
}
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
@@ -91,29 +77,5 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
if c.LocalAddr == "" {
return errors.New("localAddr is required")
}
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
if c == nil || !c.Enable {
return nil
}
if crtPath != "" || keyPath != "" {
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
}
if strings.TrimSpace(c.CacheDir) == "" {
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
}
if len(c.HostAllowList) > 0 {
for _, host := range c.HostAllowList {
if strings.TrimSpace(host) == "" {
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
}
}
}
return nil
}
+2 -4
View File
@@ -79,11 +79,9 @@ 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 {
canonicalDomain := strings.ToLower(domain)
if subDomainHost != "" && len(strings.Split(subDomainHost, ".")) < len(strings.Split(canonicalDomain, ".")) {
if strings.HasSuffix(canonicalDomain, "."+subDomainHost) {
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
}
}
-76
View File
@@ -1,76 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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)
})
}
}
+17 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
)
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
@@ -35,7 +36,22 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
errs = AppendError(errs, v.validateAuthTokenSource(c.Auth.Token, c.Auth.TokenSource))
// Validate token/tokenSource mutual exclusivity
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
// Validate tokenSource if specified
if c.Auth.TokenSource != nil {
if c.Auth.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
}
if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err)
@@ -51,9 +67,6 @@ 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
@@ -1,51 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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)
})
}
}
-4
View File
@@ -29,10 +29,6 @@ var (
"websocket",
"wss",
}
SupportedWireProtocols = []string{
"v1",
"v2",
}
SupportedAuthMethods = []v1.AuthMethod{
"token",
+16 -52
View File
@@ -18,8 +18,6 @@ import (
"sync"
"time"
"k8s.io/utils/clock"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/metric"
server "github.com/fatedier/frp/server/metrics"
@@ -39,21 +37,12 @@ func init() {
}
type serverMetrics struct {
info *ServerStatistics
clock clock.WithTicker
mu sync.Mutex
info *ServerStatistics
mu sync.Mutex
}
func newServerMetrics() *serverMetrics {
return newServerMetricsWithClock(clock.RealClock{})
}
func newServerMetricsWithClock(clk clock.WithTicker) *serverMetrics {
if clk == nil {
clk = clock.RealClock{}
}
return &serverMetrics{
clock: clk,
info: &ServerStatistics{
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
@@ -68,23 +57,14 @@ func newServerMetricsWithClock(clk clock.WithTicker) *serverMetrics {
}
func (m *serverMetrics) run() {
go m.runUntil(nil)
}
func (m *serverMetrics) runUntil(stopCh <-chan struct{}) {
ticker := m.clock.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C():
start := m.clock.Now()
go func() {
for {
time.Sleep(12 * time.Hour)
start := time.Now()
count, total := m.clearUselessInfo(time.Duration(7*24) * time.Hour)
log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, m.clock.Since(start))
case <-stopCh:
return
log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, time.Since(start))
}
}
}()
}
func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) {
@@ -95,7 +75,9 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
defer m.mu.Unlock()
total = len(m.info.ProxyStatistics)
for name, data := range m.info.ProxyStatistics {
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
if !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
time.Since(data.LastCloseTime) > continuousOfflineDuration {
delete(m.info.ProxyStatistics, name)
count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
@@ -104,20 +86,10 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
return count, total
}
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
return !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
}
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) NewClient() {
m.info.ClientCounts.Inc(1)
}
@@ -149,7 +121,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
}
proxyStats.User = user
proxyStats.ClientID = clientID
proxyStats.LastStartTime = m.clock.Now()
proxyStats.LastStartTime = time.Now()
}
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
@@ -159,7 +131,7 @@ func (m *serverMetrics) CloseProxy(name string, proxyType string) {
counter.Dec(1)
}
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
proxyStats.LastCloseTime = m.clock.Now()
proxyStats.LastCloseTime = time.Now()
}
}
@@ -239,11 +211,9 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
}
if !proxyStats.LastStartTime.IsZero() {
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
ps.LastStartAt = proxyStats.LastStartTime.Unix()
}
if !proxyStats.LastCloseTime.IsZero() {
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
}
return ps
}
@@ -253,9 +223,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
m.mu.Lock()
defer m.mu.Unlock()
filterAll := proxyType == "" || proxyType == "all"
for name, proxyStats := range m.info.ProxyStatistics {
if !filterAll && proxyStats.ProxyType != proxyType {
if proxyStats.ProxyType != proxyType {
continue
}
res = append(res, toProxyStats(name, proxyStats))
@@ -268,13 +237,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
// filterAll allows the "all proxies" API to look up a proxy by name without
// knowing its type (proxyType == "" or "all").
filterAll := proxyType == "" || proxyType == "all"
if filterAll || proxyStats.ProxyType == proxyType {
res = toProxyStats(proxyName, proxyStats)
}
if ok && proxyStats.ProxyType == proxyType {
res = toProxyStats(proxyName, proxyStats)
}
return
}
-153
View File
@@ -1,153 +0,0 @@
package mem
import (
"testing"
"time"
"github.com/stretchr/testify/require"
clocktesting "k8s.io/utils/clock/testing"
)
func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start)
metrics := newServerMetricsWithClock(clk)
metrics.NewProxy("proxy", "tcp", "user", "client-id")
require.Equal(start, metrics.info.ProxyStatistics["proxy"].LastStartTime)
closedAt := start.Add(time.Minute)
clk.SetTime(closedAt)
metrics.CloseProxy("proxy", "tcp")
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
stats := metrics.GetProxyByName("proxy")
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
require.Equal(start.Unix(), stats.LastStartAt)
require.Equal(closedAt.Unix(), stats.LastCloseAt)
}
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(25 * time.Hour))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
Name: "proxy",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
count, total := metrics.clearUselessInfo(24 * time.Hour)
require.Equal(1, count)
require.Equal(1, total)
require.Empty(metrics.info.ProxyStatistics)
}
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
cleared, total := metrics.ClearOfflineProxies()
require.Equal(1, cleared)
require.Equal(2, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
}
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
Name: "restarted",
LastStartTime: start.Add(30 * time.Second),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
Name: "same-time",
LastStartTime: start,
LastCloseTime: start,
}
cleared, total := metrics.PruneOfflineProxies()
require.Equal(1, cleared)
require.Equal(4, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
require.True(metrics.hasProxyStatistics("restarted"))
require.True(metrics.hasProxyStatistics("same-time"))
cleared, total = metrics.PruneOfflineProxies()
require.Equal(0, cleared)
require.Equal(3, total)
}
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start)
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
Name: "proxy",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
stopCh := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
metrics.runUntil(stopCh)
}()
t.Cleanup(func() {
close(stopCh)
<-done
})
require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
clk.Step(8 * 24 * time.Hour)
require.Eventually(func() bool {
return !metrics.hasProxyStatistics("proxy")
}, time.Second, time.Millisecond)
}
func (m *serverMetrics) hasProxyStatistics(name string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.info.ProxyStatistics[name]
return ok
}
-3
View File
@@ -41,8 +41,6 @@ type ProxyStats struct {
TodayTrafficOut int64
LastStartTime string
LastCloseTime string
LastStartAt int64
LastCloseAt int64
CurConns int64
}
@@ -87,5 +85,4 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int)
PruneOfflineProxies() (int, int)
}
-56
View File
@@ -1,56 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"net"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestConnReadWriteMsg(t *testing.T) {
tests := []struct {
name string
protocol string
}{
{name: "v1", protocol: wire.ProtocolV1},
{name: "v2", protocol: wire.ProtocolV2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
clientConn := NewConn(client, NewReadWriter(client, tt.protocol))
serverConn := NewConn(server, NewReadWriter(server, tt.protocol))
in := &Ping{PrivilegeKey: "key", Timestamp: 123}
errCh := make(chan error, 1)
go func() {
errCh <- clientConn.WriteMsg(in)
}()
out, err := serverConn.ReadMsg()
require.NoError(t, err)
require.Equal(t, in, out)
require.NoError(t, <-errCh)
})
}
}
+14 -87
View File
@@ -15,90 +15,10 @@
package msg
import (
"context"
"io"
"net"
"reflect"
"github.com/fatedier/frp/pkg/proto/wire"
)
type ReadWriter interface {
ReadMsg() (Message, error)
ReadMsgInto(Message) error
WriteMsg(Message) error
}
type Conn struct {
net.Conn
rw ReadWriter
}
func NewConn(conn net.Conn, rw ReadWriter) *Conn {
return &Conn{
Conn: conn,
rw: rw,
}
}
func (c *Conn) ReadMsg() (Message, error) {
return c.rw.ReadMsg()
}
func (c *Conn) ReadMsgInto(m Message) error {
return c.rw.ReadMsgInto(m)
}
func (c *Conn) WriteMsg(m Message) error {
return c.rw.WriteMsg(m)
}
func (c *Conn) Context() context.Context {
if getter, ok := c.Conn.(interface{ Context() context.Context }); ok {
return getter.Context()
}
return context.Background()
}
func (c *Conn) WithContext(ctx context.Context) {
if setter, ok := c.Conn.(interface{ WithContext(context.Context) }); ok {
setter.WithContext(ctx)
}
}
type V1ReadWriter struct {
rw io.ReadWriter
}
func NewV1ReadWriter(rw io.ReadWriter) ReadWriter {
return &V1ReadWriter{rw: rw}
}
// NewReadWriter wraps rw with the message codec for the selected wire protocol.
// An empty protocol keeps the historical v1 behavior for tests and older call sites.
func NewReadWriter(rw io.ReadWriter, wireProtocol string) ReadWriter {
switch wireProtocol {
case wire.ProtocolV2:
return NewV2ReadWriter(rw)
case "", wire.ProtocolV1:
return NewV1ReadWriter(rw)
default:
return NewV1ReadWriter(rw)
}
}
func (rw *V1ReadWriter) ReadMsg() (Message, error) {
return ReadMsg(rw.rw)
}
func (rw *V1ReadWriter) ReadMsgInto(m Message) error {
return ReadMsgInto(rw.rw, m)
}
func (rw *V1ReadWriter) WriteMsg(m Message) error {
return WriteMsg(rw.rw, m)
}
func AsyncHandler(f func(Message)) func(Message) {
return func(m Message) {
go f(m)
@@ -107,14 +27,15 @@ func AsyncHandler(f func(Message)) func(Message) {
// Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn.
type Dispatcher struct {
rw ReadWriter
rw io.ReadWriter
sendCh chan Message
doneCh chan struct{}
msgHandlers map[reflect.Type]func(Message)
sendCh chan Message
doneCh chan struct{}
msgHandlers map[reflect.Type]func(Message)
defaultHandler func(Message)
}
func NewDispatcher(rw ReadWriter) *Dispatcher {
func NewDispatcher(rw io.ReadWriter) *Dispatcher {
return &Dispatcher{
rw: rw,
sendCh: make(chan Message, 100),
@@ -135,14 +56,14 @@ func (d *Dispatcher) sendLoop() {
case <-d.doneCh:
return
case m := <-d.sendCh:
_ = d.rw.WriteMsg(m)
_ = WriteMsg(d.rw, m)
}
}
}
func (d *Dispatcher) readLoop() {
for {
m, err := d.rw.ReadMsg()
m, err := ReadMsg(d.rw)
if err != nil {
close(d.doneCh)
return
@@ -150,6 +71,8 @@ func (d *Dispatcher) readLoop() {
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
handler(m)
} else if d.defaultHandler != nil {
d.defaultHandler(m)
}
}
}
@@ -167,6 +90,10 @@ func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
d.msgHandlers[reflect.TypeOf(msg)] = handler
}
func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) {
d.defaultHandler = handler
}
func (d *Dispatcher) Done() chan struct{} {
return d.doneCh
}
+18 -19
View File
@@ -20,24 +20,24 @@ import (
)
const (
TypeLogin byte = 'o'
TypeLoginResp byte = '1'
TypeNewProxy byte = 'p'
TypeNewProxyResp byte = '2'
TypeCloseProxy byte = 'c'
TypeNewWorkConn byte = 'w'
TypeReqWorkConn byte = 'r'
TypeStartWorkConn byte = 's'
TypeNewVisitorConn byte = 'v'
TypeNewVisitorConnResp byte = '3'
TypePing byte = 'h'
TypePong byte = '4'
TypeUDPPacket byte = 'u'
TypeNatHoleVisitor byte = 'i'
TypeNatHoleClient byte = 'n'
TypeNatHoleResp byte = 'm'
TypeNatHoleSid byte = '5'
TypeNatHoleReport byte = '6'
TypeLogin = 'o'
TypeLoginResp = '1'
TypeNewProxy = 'p'
TypeNewProxyResp = '2'
TypeCloseProxy = 'c'
TypeNewWorkConn = 'w'
TypeReqWorkConn = 'r'
TypeStartWorkConn = 's'
TypeNewVisitorConn = 'v'
TypeNewVisitorConnResp = '3'
TypePing = 'h'
TypePong = '4'
TypeUDPPacket = 'u'
TypeNatHoleVisitor = 'i'
TypeNatHoleClient = 'n'
TypeNatHoleResp = 'm'
TypeNatHoleSid = '5'
TypeNatHoleReport = '6'
)
var msgTypeMap = map[byte]any{
@@ -124,7 +124,6 @@ type NewProxy struct {
Headers map[string]string `json:"headers,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
HTTPRedirect bool `json:"http_redirect,omitempty"`
// stcp, sudp, xtcp
Sk string `json:"sk,omitempty"`
-55
View File
@@ -1,55 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"reflect"
"testing"
"github.com/stretchr/testify/require"
)
func TestV1MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, byte('o'), TypeLogin)
require.Equal(t, byte('1'), TypeLoginResp)
require.Equal(t, byte('p'), TypeNewProxy)
require.Equal(t, byte('2'), TypeNewProxyResp)
require.Equal(t, byte('c'), TypeCloseProxy)
require.Equal(t, byte('w'), TypeNewWorkConn)
require.Equal(t, byte('r'), TypeReqWorkConn)
require.Equal(t, byte('s'), TypeStartWorkConn)
require.Equal(t, byte('v'), TypeNewVisitorConn)
require.Equal(t, byte('3'), TypeNewVisitorConnResp)
require.Equal(t, byte('h'), TypePing)
require.Equal(t, byte('4'), TypePong)
require.Equal(t, byte('u'), TypeUDPPacket)
require.Equal(t, byte('i'), TypeNatHoleVisitor)
require.Equal(t, byte('n'), TypeNatHoleClient)
require.Equal(t, byte('m'), TypeNatHoleResp)
require.Equal(t, byte('5'), TypeNatHoleSid)
require.Equal(t, byte('6'), TypeNatHoleReport)
}
func TestMessageTypeMapIsCompleteAndUnique(t *testing.T) {
require.Len(t, msgTypeMap, 18)
msgTypes := make(map[reflect.Type]struct{}, len(msgTypeMap))
for _, m := range msgTypeMap {
msgType := reflect.TypeOf(m)
require.NotContains(t, msgTypes, msgType)
msgTypes[msgType] = struct{}{}
}
}
-199
View File
@@ -1,199 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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
@@ -1,338 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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
@@ -1,248 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
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)
})
}
-193
View File
@@ -1,193 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"reflect"
"github.com/fatedier/frp/pkg/proto/wire"
)
const (
V2TypeLogin uint16 = 1
V2TypeLoginResp uint16 = 2
V2TypeNewProxy uint16 = 3
V2TypeNewProxyResp uint16 = 4
V2TypeCloseProxy uint16 = 5
V2TypeNewWorkConn uint16 = 6
V2TypeReqWorkConn uint16 = 7
V2TypeStartWorkConn uint16 = 8
V2TypeNewVisitorConn uint16 = 9
V2TypeNewVisitorConnResp uint16 = 10
V2TypePing uint16 = 11
V2TypePong uint16 = 12
V2TypeUDPPacket uint16 = 13
V2TypeNatHoleVisitor uint16 = 14
V2TypeNatHoleClient uint16 = 15
V2TypeNatHoleResp uint16 = 16
V2TypeNatHoleSid uint16 = 17
V2TypeNatHoleReport uint16 = 18
V2TypeUDPPacketBinary uint16 = 19
)
var v2MsgTypeMap = map[uint16]any{
V2TypeLogin: Login{},
V2TypeLoginResp: LoginResp{},
V2TypeNewProxy: NewProxy{},
V2TypeNewProxyResp: NewProxyResp{},
V2TypeCloseProxy: CloseProxy{},
V2TypeNewWorkConn: NewWorkConn{},
V2TypeReqWorkConn: ReqWorkConn{},
V2TypeStartWorkConn: StartWorkConn{},
V2TypeNewVisitorConn: NewVisitorConn{},
V2TypeNewVisitorConnResp: NewVisitorConnResp{},
V2TypePing: Ping{},
V2TypePong: Pong{},
V2TypeUDPPacket: UDPPacket{},
V2TypeNatHoleVisitor: NatHoleVisitor{},
V2TypeNatHoleClient: NatHoleClient{},
V2TypeNatHoleResp: NatHoleResp{},
V2TypeNatHoleSid: NatHoleSid{},
V2TypeNatHoleReport: NatHoleReport{},
}
var v2MsgReflectTypeMap, v2MsgTypeIDMap = buildV2MsgTypeMaps()
func buildV2MsgTypeMaps() (map[uint16]reflect.Type, map[reflect.Type]uint16) {
reflectTypeMap := make(map[uint16]reflect.Type, len(v2MsgTypeMap))
typeIDMap := make(map[reflect.Type]uint16, len(v2MsgTypeMap))
for typeID, m := range v2MsgTypeMap {
t := reflect.TypeOf(m)
reflectTypeMap[typeID] = t
typeIDMap[t] = typeID
}
return reflectTypeMap, typeIDMap
}
type V2ReadWriter struct {
conn *wire.Conn
}
func NewV2ReadWriter(rw io.ReadWriter) *V2ReadWriter {
return NewV2ReadWriterWithConn(wire.NewConn(rw))
}
func NewV2ReadWriterWithConn(conn *wire.Conn) *V2ReadWriter {
return &V2ReadWriter{conn: conn}
}
func (rw *V2ReadWriter) WireConn() *wire.Conn {
return rw.conn
}
func (rw *V2ReadWriter) ReadMsg() (Message, error) {
f, err := rw.conn.ReadFrame()
if err != nil {
return nil, err
}
return DecodeV2MessageFrame(f)
}
func (rw *V2ReadWriter) ReadMsgInto(m Message) error {
f, err := rw.conn.ReadFrame()
if err != nil {
return err
}
return DecodeV2MessageFrameInto(f, m)
}
func (rw *V2ReadWriter) WriteMsg(m Message) error {
f, err := EncodeV2MessageFrame(m)
if err != nil {
return err
}
return rw.conn.WriteFrame(f)
}
func DecodeV2MessageFrame(f *wire.Frame) (Message, error) {
if f.Type != wire.FrameTypeMessage {
return nil, fmt.Errorf("unexpected frame type %d, want %d", f.Type, wire.FrameTypeMessage)
}
if len(f.Payload) < 2 {
return nil, fmt.Errorf("message frame payload too short")
}
typeID := binary.BigEndian.Uint16(f.Payload[:2])
t, ok := v2MsgReflectTypeMap[typeID]
if !ok {
return nil, fmt.Errorf("unknown v2 message type %d", typeID)
}
m := reflect.New(t).Interface()
if err := json.Unmarshal(f.Payload[2:], m); err != nil {
return nil, err
}
return m, nil
}
func DecodeV2MessageFrameInto(f *wire.Frame, out Message) error {
if f.Type != wire.FrameTypeMessage {
return fmt.Errorf("unexpected frame type %d, want %d", f.Type, wire.FrameTypeMessage)
}
if len(f.Payload) < 2 {
return fmt.Errorf("message frame payload too short")
}
typeID := binary.BigEndian.Uint16(f.Payload[:2])
outType := reflect.TypeOf(out)
if outType == nil || outType.Kind() != reflect.Pointer {
return fmt.Errorf("message target must be a pointer")
}
elemType := outType.Elem()
expectedTypeID, ok := v2MsgTypeIDMap[elemType]
if !ok {
return fmt.Errorf("unknown v2 message type %s", elemType.String())
}
if typeID != expectedTypeID {
actualType, ok := v2MsgReflectTypeMap[typeID]
if !ok {
return fmt.Errorf("unknown v2 message type %d", typeID)
}
return fmt.Errorf("unexpected message type %s, want %s", actualType.String(), elemType.String())
}
return json.Unmarshal(f.Payload[2:], out)
}
func EncodeV2MessageFrame(m Message) (*wire.Frame, error) {
t := reflect.TypeOf(m)
if t == nil {
return nil, fmt.Errorf("nil message")
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
typeID, ok := v2MsgTypeIDMap[t]
if !ok {
return nil, fmt.Errorf("unknown v2 message type %s", t.String())
}
content, err := json.Marshal(m)
if err != nil {
return nil, err
}
payload := make([]byte, 2+len(content))
binary.BigEndian.PutUint16(payload[:2], typeID)
copy(payload[2:], content)
return &wire.Frame{
Type: wire.FrameTypeMessage,
Payload: payload,
}, nil
}
-143
View File
@@ -1,143 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestV2ReadWriterRoundTrip(t *testing.T) {
var buf bytes.Buffer
rw := NewV2ReadWriter(&buf)
in := &Login{
Version: "test-version",
RunID: "run-id",
User: "user",
}
require.NoError(t, rw.WriteMsg(in))
out, err := rw.ReadMsg()
require.NoError(t, err)
require.Equal(t, in, out)
}
func TestNewReadWriter(t *testing.T) {
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
}
func TestNewReadWriterEncoding(t *testing.T) {
for _, wireProtocol := range []string{"", wire.ProtocolV1} {
var legacy bytes.Buffer
legacyRW := NewReadWriter(&legacy, wireProtocol)
require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.NotEmpty(t, legacy.Bytes())
require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
}
var v2 bytes.Buffer
v2RW := NewReadWriter(&v2, wire.ProtocolV2)
require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
frame, err := wire.NewConn(&v2).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(1), V2TypeLogin)
require.Equal(t, uint16(2), V2TypeLoginResp)
require.Equal(t, uint16(3), V2TypeNewProxy)
require.Equal(t, uint16(4), V2TypeNewProxyResp)
require.Equal(t, uint16(5), V2TypeCloseProxy)
require.Equal(t, uint16(6), V2TypeNewWorkConn)
require.Equal(t, uint16(7), V2TypeReqWorkConn)
require.Equal(t, uint16(8), V2TypeStartWorkConn)
require.Equal(t, uint16(9), V2TypeNewVisitorConn)
require.Equal(t, uint16(10), V2TypeNewVisitorConnResp)
require.Equal(t, uint16(11), V2TypePing)
require.Equal(t, uint16(12), V2TypePong)
require.Equal(t, uint16(13), V2TypeUDPPacket)
require.Equal(t, uint16(14), V2TypeNatHoleVisitor)
require.Equal(t, uint16(15), V2TypeNatHoleClient)
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) {
frame, err := EncodeV2MessageFrame(&ReqWorkConn{})
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.Len(t, frame.Payload, 4)
require.Equal(t, V2TypeReqWorkConn, binary.BigEndian.Uint16(frame.Payload[:2]))
out, err := DecodeV2MessageFrame(frame)
require.NoError(t, err)
require.IsType(t, &ReqWorkConn{}, out)
}
func TestDecodeV2MessageFrameInto(t *testing.T) {
in := &StartWorkConn{ProxyName: "tcp", SrcAddr: "127.0.0.1", SrcPort: 1234}
frame, err := EncodeV2MessageFrame(in)
require.NoError(t, err)
var out StartWorkConn
require.NoError(t, DecodeV2MessageFrameInto(frame, &out))
require.Equal(t, *in, out)
}
func TestDecodeV2MessageFrameRejectsInvalidFrame(t *testing.T) {
_, err := DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeClientHello})
require.ErrorContains(t, err, "unexpected frame type")
_, err = DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: []byte{0}})
require.ErrorContains(t, err, "payload too short")
payload := make([]byte, 4)
binary.BigEndian.PutUint16(payload[:2], 65535)
copy(payload[2:], []byte("{}"))
_, err = DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
require.ErrorContains(t, err, "unknown v2 message type")
}
func TestDecodeV2MessageFrameIntoRejectsWrongTarget(t *testing.T) {
frame, err := EncodeV2MessageFrame(&ReqWorkConn{})
require.NoError(t, err)
var out StartWorkConn
err = DecodeV2MessageFrameInto(frame, &out)
require.ErrorContains(t, err, "unexpected message type")
err = DecodeV2MessageFrameInto(frame, StartWorkConn{})
require.ErrorContains(t, err, "must be a pointer")
}
func TestEncodeV2MessageFrameRejectsUnknownMessage(t *testing.T) {
_, err := EncodeV2MessageFrame(struct{}{})
require.ErrorContains(t, err, "unknown v2 message type")
}
+23 -41
View File
@@ -21,7 +21,6 @@ import (
"time"
"github.com/samber/lo"
"k8s.io/utils/clock"
)
var (
@@ -145,19 +144,19 @@ func getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, Recomman
return behaviors[index].A, behaviors[index].B
}
func getBehaviorScoresByMode(mode int, defaultScore int) []*behaviorScore {
func getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore {
return getBehaviorScoresByMode2(mode, defaultScore, defaultScore)
}
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*behaviorScore {
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {
behaviors := getBehaviorByMode(mode)
scores := make([]*behaviorScore, 0, len(behaviors))
scores := make([]*BehaviorScore, 0, len(behaviors))
for i := range behaviors {
score := receiverScore
if behaviors[i].A.Role == DetectRoleSender {
score = senderScore
}
scores = append(scores, &behaviorScore{Mode: mode, Index: i, Score: score})
scores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score})
}
return scores
}
@@ -171,18 +170,14 @@ type RecommandBehavior struct {
ListenRandomPorts int
}
type makeHoleRecords struct {
type MakeHoleRecords struct {
mu sync.Mutex
scores []*behaviorScore
clock clock.PassiveClock
lastUpdateTime time.Time
scores []*BehaviorScore
LastUpdateTime time.Time
}
func newMakeHoleRecordsWithClock(c, v *NatFeature, clk clock.PassiveClock) *makeHoleRecords {
if clk == nil {
clk = clock.RealClock{}
}
scores := []*behaviorScore{}
func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
scores := []*BehaviorScore{}
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
appendMode0 := func() {
switch {
@@ -217,17 +212,13 @@ func newMakeHoleRecordsWithClock(c, v *NatFeature, clk clock.PassiveClock) *make
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)
scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)
}
return &makeHoleRecords{
scores: scores,
clock: clk,
lastUpdateTime: clk.Now(),
}
return &MakeHoleRecords{scores: scores, LastUpdateTime: time.Now()}
}
func (mhr *makeHoleRecords) reportSuccess(mode int, index int) {
func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
mhr.mu.Lock()
defer mhr.mu.Unlock()
mhr.lastUpdateTime = mhr.clock.Now()
mhr.LastUpdateTime = time.Now()
for i := range mhr.scores {
score := mhr.scores[i]
if score.Mode != mode || score.Index != index {
@@ -240,22 +231,22 @@ func (mhr *makeHoleRecords) reportSuccess(mode int, index int) {
}
}
func (mhr *makeHoleRecords) recommand() (mode, index int) {
func (mhr *MakeHoleRecords) Recommand() (mode, index int) {
mhr.mu.Lock()
defer mhr.mu.Unlock()
if len(mhr.scores) == 0 {
return 0, 0
}
maxScore := slices.MaxFunc(mhr.scores, func(a, b *behaviorScore) int {
maxScore := slices.MaxFunc(mhr.scores, func(a, b *BehaviorScore) int {
return cmp.Compare(a.Score, b.Score)
})
maxScore.Score--
mhr.lastUpdateTime = mhr.clock.Now()
mhr.LastUpdateTime = time.Now()
return maxScore.Mode, maxScore.Index
}
type behaviorScore struct {
type BehaviorScore struct {
Mode int
Index int
// between -10 and 10
@@ -264,25 +255,16 @@ type behaviorScore struct {
type Analyzer struct {
// key is client ip + visitor ip
records map[string]*makeHoleRecords
records map[string]*MakeHoleRecords
dataReserveDuration time.Duration
clock clock.PassiveClock
mu sync.Mutex
}
func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {
return newAnalyzerWithClock(dataReserveDuration, clock.RealClock{})
}
func newAnalyzerWithClock(dataReserveDuration time.Duration, clk clock.PassiveClock) *Analyzer {
if clk == nil {
clk = clock.RealClock{}
}
return &Analyzer{
records: make(map[string]*makeHoleRecords),
records: make(map[string]*MakeHoleRecords),
dataReserveDuration: dataReserveDuration,
clock: clk,
}
}
@@ -290,12 +272,12 @@ func (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, in
a.mu.Lock()
records, ok := a.records[key]
if !ok {
records = newMakeHoleRecordsWithClock(c, v, a.clock)
records = NewMakeHoleRecords(c, v)
a.records[key] = records
}
a.mu.Unlock()
mode, index = records.recommand()
mode, index = records.Recommand()
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
switch mode {
@@ -325,11 +307,11 @@ func (a *Analyzer) ReportSuccess(key string, mode, index int) {
if !ok {
return
}
records.reportSuccess(mode, index)
records.ReportSuccess(mode, index)
}
func (a *Analyzer) Clean() (int, int) {
now := a.clock.Now()
now := time.Now()
total := 0
count := 0
@@ -339,7 +321,7 @@ func (a *Analyzer) Clean() (int, int) {
total = len(a.records)
// clean up records that have not been used for a period of time.
for key, records := range a.records {
if now.Sub(records.lastUpdateTime) > a.dataReserveDuration {
if now.Sub(records.LastUpdateTime) > a.dataReserveDuration {
delete(a.records, key)
count++
}
-33
View File
@@ -1,33 +0,0 @@
package nathole
import (
"testing"
"time"
"github.com/stretchr/testify/require"
clocktesting "k8s.io/utils/clock/testing"
)
func TestAnalyzerUsesClockForRecordTimestamps(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start)
analyzer := newAnalyzerWithClock(time.Hour, clk)
clientFeature := &NatFeature{NatType: EasyNAT, Behavior: BehaviorNoChange}
visitorFeature := &NatFeature{NatType: EasyNAT, Behavior: BehaviorNoChange}
mode, index, _, _ := analyzer.GetRecommandBehaviors("key", clientFeature, visitorFeature)
require.Equal(start, analyzer.records["key"].lastUpdateTime)
updatedAt := start.Add(time.Minute)
clk.SetTime(updatedAt)
analyzer.ReportSuccess("key", mode, index)
require.Equal(updatedAt, analyzer.records["key"].lastUpdateTime)
clk.SetTime(start.Add(2 * time.Hour))
count, total := analyzer.Clean()
require.Equal(1, count)
require.Equal(1, total)
require.Empty(analyzer.records)
}
+39 -45
View File
@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
// Make hole-punching decisions based on the NAT information of the client and visitor.
vResp, cResp, err := c.analysis(session)
if err != nil {
log.Debugf("sid [%s] analysis error: %v", sid, err)
log.Debugf("sid [%s] analysis error: %v", err)
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
}
@@ -326,16 +326,40 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
}
protocol := vm.Protocol
vResp := newNatHoleResponse(
vm.TransactionID, session.sid, protocol, mode,
cm.MappedAddrs, cm.AssistedAddrs, vBehavior,
timeoutMs-vBehavior.SendDelayMs, cNatFeature.PortsDifference,
)
cResp := newNatHoleResponse(
cm.TransactionID, session.sid, protocol, mode,
vm.MappedAddrs, vm.AssistedAddrs, cBehavior,
timeoutMs-cBehavior.SendDelayMs, vNatFeature.PortsDifference,
)
vResp := &msg.NatHoleResp{
TransactionID: vm.TransactionID,
Sid: session.sid,
Protocol: protocol,
CandidateAddrs: slices.Compact(cm.MappedAddrs),
AssistedAddrs: slices.Compact(cm.AssistedAddrs),
DetectBehavior: msg.NatHoleDetectBehavior{
Mode: mode,
Role: vBehavior.Role,
TTL: vBehavior.TTL,
SendDelayMs: vBehavior.SendDelayMs,
ReadTimeoutMs: timeoutMs - vBehavior.SendDelayMs,
SendRandomPorts: vBehavior.PortsRandomNumber,
ListenRandomPorts: vBehavior.ListenRandomPorts,
CandidatePorts: getRangePorts(cm.MappedAddrs, cNatFeature.PortsDifference, vBehavior.PortsRangeNumber),
},
}
cResp := &msg.NatHoleResp{
TransactionID: cm.TransactionID,
Sid: session.sid,
Protocol: protocol,
CandidateAddrs: slices.Compact(vm.MappedAddrs),
AssistedAddrs: slices.Compact(vm.AssistedAddrs),
DetectBehavior: msg.NatHoleDetectBehavior{
Mode: mode,
Role: cBehavior.Role,
TTL: cBehavior.TTL,
SendDelayMs: cBehavior.SendDelayMs,
ReadTimeoutMs: timeoutMs - cBehavior.SendDelayMs,
SendRandomPorts: cBehavior.PortsRandomNumber,
ListenRandomPorts: cBehavior.ListenRandomPorts,
CandidatePorts: getRangePorts(vm.MappedAddrs, vNatFeature.PortsDifference, cBehavior.PortsRangeNumber),
},
}
log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s",
session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)
@@ -344,38 +368,6 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
return vResp, cResp, nil
}
func newNatHoleResponse(
transactionID string,
sid string,
protocol string,
mode int,
candidateAddrs []string,
assistedAddrs []string,
behavior RecommandBehavior,
readTimeoutMs int,
portsDifference int,
) *msg.NatHoleResp {
compactCandidateAddrs := slices.Compact(candidateAddrs)
compactAssistedAddrs := slices.Compact(assistedAddrs)
return &msg.NatHoleResp{
TransactionID: transactionID,
Sid: sid,
Protocol: protocol,
CandidateAddrs: compactCandidateAddrs,
AssistedAddrs: compactAssistedAddrs,
DetectBehavior: msg.NatHoleDetectBehavior{
Mode: mode,
Role: behavior.Role,
TTL: behavior.TTL,
SendDelayMs: behavior.SendDelayMs,
ReadTimeoutMs: readTimeoutMs,
SendRandomPorts: behavior.PortsRandomNumber,
ListenRandomPorts: behavior.ListenRandomPorts,
CandidatePorts: getRangePorts(candidateAddrs, portsDifference, behavior.PortsRangeNumber),
},
}
}
func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if maxNumber <= 0 {
return nil
@@ -385,6 +377,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if !isLast {
return nil
}
ports := make([]msg.PortsRange, 0, 1)
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil
@@ -393,8 +386,9 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if err != nil {
return nil
}
return []msg.PortsRange{{
ports = append(ports, msg.PortsRange{
From: max(port-difference-5, port-maxNumber, 1),
To: min(port+difference+5, port+maxNumber, 65535),
}}
})
return ports
}
+70 -30
View File
@@ -15,24 +15,31 @@
package nathole
import (
"errors"
"fmt"
"net"
"time"
"github.com/fatedier/golib/net/stun"
"github.com/pion/stun/v3"
)
var responseTimeout = 3 * time.Second
type Message struct {
Body []byte
Addr string
}
// If the localAddr is empty, it will listen on a random port.
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
// create a discoverConn and get response from messageChan
discoverConn, err := listen(localAddr)
if err != nil {
return nil, nil, err
}
defer discoverConn.Close()
go discoverConn.readLoop()
addresses := make([]string, 0, len(stunServers))
for _, addr := range stunServers {
// get external address from stun server
@@ -51,9 +58,10 @@ type stunResponse struct {
}
type discoverConn struct {
conn *net.UDPConn
client *stun.Client
localAddr net.Addr
conn *net.UDPConn
localAddr net.Addr
messageChan chan *Message
}
func listen(localAddr string) (*discoverConn, error) {
@@ -69,50 +77,82 @@ func listen(localAddr string) (*discoverConn, error) {
if err != nil {
return nil, err
}
client, err := stun.NewClient(conn)
if err != nil {
_ = conn.Close()
return nil, err
}
return &discoverConn{
conn: conn,
client: client,
localAddr: conn.LocalAddr(),
conn: conn,
localAddr: conn.LocalAddr(),
messageChan: make(chan *Message, 10),
}, nil
}
func (c *discoverConn) Close() error {
if c.messageChan != nil {
close(c.messageChan)
c.messageChan = nil
}
return c.conn.Close()
}
func (c *discoverConn) readLoop() {
for {
buf := make([]byte, 1024)
n, addr, err := c.conn.ReadFromUDP(buf)
if err != nil {
return
}
buf = buf[:n]
c.messageChan <- &Message{
Body: buf,
Addr: addr.String(),
}
}
}
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
if err != nil {
return nil, err
}
transaction, err := stun.NewBindingTransaction(serverAddr)
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
if err != nil {
return nil, err
}
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
return nil, err
}
response, err := c.client.Do(transaction)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return nil, fmt.Errorf("wait response from stun server timeout")
}
return nil, err
}
resp := &stunResponse{}
if response.MappedAddr != nil {
resp.externalAddr = response.MappedAddr.String()
if err = request.NewTransactionID(); err != nil {
return nil, err
}
if response.OtherAddr != nil {
resp.otherAddr = response.OtherAddr.String()
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
return nil, err
}
var m stun.Message
select {
case msg := <-c.messageChan:
m.Raw = msg.Body
if err := m.Decode(); err != nil {
return nil, err
}
case <-time.After(responseTimeout):
return nil, fmt.Errorf("wait response from stun server timeout")
}
xorAddrGetter := &stun.XORMappedAddress{}
mappedAddrGetter := &stun.MappedAddress{}
changedAddrGetter := ChangedAddress{}
otherAddrGetter := &stun.OtherAddress{}
resp := &stunResponse{}
if err := mappedAddrGetter.GetFrom(&m); err == nil {
resp.externalAddr = mappedAddrGetter.String()
}
if err := xorAddrGetter.GetFrom(&m); err == nil {
resp.externalAddr = xorAddrGetter.String()
}
if err := changedAddrGetter.GetFrom(&m); err == nil {
resp.otherAddr = changedAddrGetter.String()
}
if err := otherAddrGetter.GetFrom(&m); err == nil {
resp.otherAddr = otherAddrGetter.String()
}
return resp, nil
}
-382
View File
@@ -1,382 +0,0 @@
package nathole
import (
"encoding/binary"
"errors"
"fmt"
"net"
"testing"
"time"
"github.com/fatedier/golib/net/stun"
"github.com/stretchr/testify/require"
)
const (
testBindingRequest = 0x0001
testBindingSuccess = 0x0101
testBindingError = 0x0111
testMagicCookie = 0x2112a442
testAttrMapped = 0x0001
testAttrChanged = 0x0005
testAttrErrorCode = 0x0009
testAttrXORMapped = 0x0020
testAttrOther = 0x802c
testSTUNHeaderSize = 20
testSTUNServerLimit = time.Second
)
type testSTUNAttribute struct {
typ uint16
value []byte
}
type testSTUNExchange struct {
source *net.UDPAddr
err error
}
func listenTestUDP4(t *testing.T) *net.UDPConn {
t.Helper()
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
return conn
}
func serveOneSTUNRequest(
server *net.UDPConn,
buildResponse func([]byte, *net.UDPAddr) ([]byte, error),
) <-chan testSTUNExchange {
done := make(chan testSTUNExchange, 1)
go func() {
if err := server.SetDeadline(time.Now().Add(testSTUNServerLimit)); err != nil {
done <- testSTUNExchange{err: err}
return
}
buffer := make([]byte, 1024)
n, source, err := server.ReadFromUDP(buffer)
if err == nil && buildResponse != nil {
var response []byte
response, err = buildResponse(buffer[:n], source)
if err == nil && response != nil {
_, err = server.WriteToUDP(response, source)
}
}
done <- testSTUNExchange{source: source, err: err}
}()
return done
}
func waitSTUNExchange(t *testing.T, done <-chan testSTUNExchange) *net.UDPAddr {
t.Helper()
select {
case exchange := <-done:
require.NoError(t, exchange.err)
return exchange.source
case <-time.After(testSTUNServerLimit):
t.Fatal("timed out waiting for local STUN server")
return nil
}
}
func makeTestSTUNResponse(request []byte, typ uint16, attributes ...testSTUNAttribute) ([]byte, error) {
if len(request) != testSTUNHeaderSize || binary.BigEndian.Uint16(request[0:2]) != testBindingRequest ||
binary.BigEndian.Uint32(request[4:8]) != testMagicCookie {
return nil, fmt.Errorf("invalid Binding request")
}
length := 0
for _, attribute := range attributes {
length += 4 + (len(attribute.value)+3)&^3
}
response := make([]byte, testSTUNHeaderSize, testSTUNHeaderSize+length)
binary.BigEndian.PutUint16(response[0:2], typ)
binary.BigEndian.PutUint16(response[2:4], uint16(length))
binary.BigEndian.PutUint32(response[4:8], testMagicCookie)
copy(response[8:20], request[8:20])
for _, attribute := range attributes {
start := len(response)
paddedLength := (len(attribute.value) + 3) &^ 3
response = append(response, make([]byte, 4+paddedLength)...)
binary.BigEndian.PutUint16(response[start:start+2], attribute.typ)
binary.BigEndian.PutUint16(response[start+2:start+4], uint16(len(attribute.value)))
copy(response[start+4:], attribute.value)
}
return response, nil
}
func testIPv4AddressValue(ip net.IP, port int, xor bool) []byte {
value := make([]byte, 8)
value[1] = 0x01
binary.BigEndian.PutUint16(value[2:4], uint16(port))
copy(value[4:], ip.To4())
if xor {
binary.BigEndian.PutUint16(value[2:4], binary.BigEndian.Uint16(value[2:4])^uint16(testMagicCookie>>16))
for i := range 4 {
value[4+i] ^= byte(uint32(testMagicCookie) >> uint(24-8*i))
}
}
return value
}
func TestDiscoverReusesLocalPortAndPreservesNATClassification(t *testing.T) {
tests := []struct {
name string
secondMapped string
secondMappedPort int
wantNATType string
wantBehavior string
}{
{
name: "same mapped address",
secondMapped: "198.51.100.10:40000",
secondMappedPort: 40000,
wantNATType: EasyNAT,
wantBehavior: BehaviorNoChange,
},
{
name: "different mapped port",
secondMapped: "198.51.100.10:40001",
secondMappedPort: 40001,
wantNATType: HardNAT,
wantBehavior: BehaviorPortChanged,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
primary := listenTestUDP4(t)
alternate := listenTestUDP4(t)
alternateAddr := alternate.LocalAddr().(*net.UDPAddr)
primaryDone := serveOneSTUNRequest(primary, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
return makeTestSTUNResponse(request, testBindingSuccess,
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), 40000, true)},
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(alternateAddr.IP, alternateAddr.Port, false)},
)
})
alternateDone := serveOneSTUNRequest(alternate, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
return makeTestSTUNResponse(request, testBindingSuccess,
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), tt.secondMappedPort, true)},
)
})
addresses, localAddr, err := Discover([]string{primary.LocalAddr().String()}, "")
require.NoError(t, err)
require.Equal(t, []string{"198.51.100.10:40000", tt.secondMapped}, addresses)
primarySource := waitSTUNExchange(t, primaryDone)
alternateSource := waitSTUNExchange(t, alternateDone)
require.Equal(t, primarySource.Port, alternateSource.Port)
require.Equal(t, localAddr.(*net.UDPAddr).Port, primarySource.Port)
feature, err := ClassifyNATFeature(addresses, nil)
require.NoError(t, err)
require.Equal(t, tt.wantNATType, feature.NatType)
require.Equal(t, tt.wantBehavior, feature.Behavior)
})
}
}
func TestDoSTUNRequestMapsLegacyAndModernAddresses(t *testing.T) {
tests := []struct {
name string
attributes []testSTUNAttribute
wantExternal string
wantOther string
}{
{
name: "legacy",
attributes: []testSTUNAttribute{
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
},
wantExternal: "192.0.2.1:1000",
wantOther: "192.0.2.2:2000",
},
{
name: "modern takes precedence",
attributes: []testSTUNAttribute{
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("198.51.100.2"), 4000, false)},
},
wantExternal: "198.51.100.1:3000",
wantOther: "198.51.100.2:4000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := listenTestUDP4(t)
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
return makeTestSTUNResponse(request, testBindingSuccess, tt.attributes...)
})
conn, err := listen("")
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
response, err := conn.doSTUNRequest(server.LocalAddr().String())
require.NoError(t, err)
require.Equal(t, tt.wantExternal, response.externalAddr)
require.Equal(t, tt.wantOther, response.otherAddr)
waitSTUNExchange(t, done)
})
}
}
func TestSTUNResponseErrorsAndMissingAddresses(t *testing.T) {
tests := []struct {
name string
buildResponse func([]byte, *net.UDPAddr) ([]byte, error)
request func(*discoverConn, string) error
checkError func(*testing.T, error)
}{
{
name: "correlated malformed response",
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
response, err := makeTestSTUNResponse(request, testBindingSuccess)
if err == nil {
binary.BigEndian.PutUint16(response[2:4], 4)
}
return response, err
},
request: func(conn *discoverConn, server string) error {
_, err := conn.doSTUNRequest(server)
return err
},
checkError: func(t *testing.T, err error) {
require.ErrorIs(t, err, stun.ErrMalformedResponse)
},
},
{
name: "Binding error response",
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
return makeTestSTUNResponse(request, testBindingError, testSTUNAttribute{
typ: testAttrErrorCode,
value: []byte{0, 0, 4, 20, 'U', 'n', 'k', 'n', 'o', 'w', 'n'},
})
},
request: func(conn *discoverConn, server string) error {
_, err := conn.doSTUNRequest(server)
return err
},
checkError: func(t *testing.T, err error) {
var responseErr *stun.ResponseError
require.ErrorAs(t, err, &responseErr)
require.Equal(t, 420, responseErr.Code)
},
},
{
name: "missing mapped address",
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
return makeTestSTUNResponse(request, testBindingSuccess,
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
)
},
request: func(conn *discoverConn, server string) error {
_, err := conn.discoverFromStunServer(server)
return err
},
checkError: func(t *testing.T, err error) {
require.EqualError(t, err, "no external address found")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := listenTestUDP4(t)
done := serveOneSTUNRequest(server, tt.buildResponse)
conn, err := listen("")
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
err = tt.request(conn, server.LocalAddr().String())
tt.checkError(t, err)
waitSTUNExchange(t, done)
})
}
t.Run("missing other address", func(t *testing.T) {
server := listenTestUDP4(t)
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
return makeTestSTUNResponse(request, testBindingSuccess,
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
)
})
_, err := Prepare([]string{server.LocalAddr().String()}, PrepareOptions{})
require.EqualError(t, err, "discover error: not enough addresses")
waitSTUNExchange(t, done)
})
}
func TestSTUNTimeoutUsesCallerDeadlineWithoutRetry(t *testing.T) {
originalTimeout := responseTimeout
responseTimeout = 50 * time.Millisecond
t.Cleanup(func() { responseTimeout = originalTimeout })
server := listenTestUDP4(t)
done := serveOneSTUNRequest(server, nil)
conn, err := listen("")
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
_, err = conn.doSTUNRequest(server.LocalAddr().String())
require.EqualError(t, err, "wait response from stun server timeout")
waitSTUNExchange(t, done)
require.NoError(t, server.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
_, _, err = server.ReadFromUDP(make([]byte, 1))
var netErr net.Error
require.ErrorAs(t, err, &netErr)
require.True(t, netErr.Timeout())
}
func TestSTUNClientLeavesSocketAndDeadlineWithCaller(t *testing.T) {
originalTimeout := responseTimeout
responseTimeout = 100 * time.Millisecond
t.Cleanup(func() { responseTimeout = originalTimeout })
server := listenTestUDP4(t)
unrelated := listenTestUDP4(t)
done := serveOneSTUNRequest(server, func(request []byte, source *net.UDPAddr) ([]byte, error) {
response, err := makeTestSTUNResponse(request, testBindingSuccess,
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.5"), 5000, true)},
)
if err != nil {
return nil, err
}
if _, err := unrelated.WriteToUDP(response, source); err != nil {
return nil, err
}
return response, nil
})
conn, err := listen("")
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
response, err := conn.doSTUNRequest(server.LocalAddr().String())
require.NoError(t, err)
require.Equal(t, "198.51.100.5:5000", response.externalAddr)
waitSTUNExchange(t, done)
_, _, err = conn.conn.ReadFromUDP(make([]byte, 1))
var netErr net.Error
require.True(t, errors.As(err, &netErr))
require.True(t, netErr.Timeout())
require.NoError(t, conn.conn.SetDeadline(time.Time{}))
require.NoError(t, server.SetReadDeadline(time.Now().Add(testSTUNServerLimit)))
_, err = conn.conn.WriteToUDP([]byte{1}, server.LocalAddr().(*net.UDPAddr))
require.NoError(t, err)
_, source, err := server.ReadFromUDP(make([]byte, 1))
require.NoError(t, err)
require.Equal(t, conn.localAddr.(*net.UDPAddr).Port, source.Port)
}
+16
View File
@@ -18,8 +18,10 @@ import (
"bytes"
"fmt"
"net"
"strconv"
"github.com/fatedier/golib/crypto"
"github.com/pion/stun/v3"
"github.com/fatedier/frp/pkg/msg"
)
@@ -46,6 +48,20 @@ func DecodeMessageInto(data, key []byte, m msg.Message) error {
return msg.ReadMsgInto(bytes.NewReader(buf), m)
}
type ChangedAddress struct {
IP net.IP
Port int
}
func (s *ChangedAddress) GetFrom(m *stun.Message) error {
a := (*stun.MappedAddress)(s)
return a.GetFromAs(m, stun.AttrChangedAddress)
}
func (s *ChangedAddress) String() string {
return net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port))
}
func ListAllLocalIPs() ([]net.IP, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
-212
View File
@@ -1,212 +0,0 @@
// Copyright 2026 The LoliaTeam Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/log"
)
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
if auto == nil || !auto.Enable {
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
}
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
}
hostSet := make(map[string]struct{})
hosts := make([]string, 0, len(auto.HostAllowList))
addHost := func(host string) {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return
}
if strings.Contains(host, "*") {
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
return
}
if _, ok := hostSet[host]; ok {
return
}
hostSet[host] = struct{}{}
hosts = append(hosts, host)
}
for _, host := range auto.HostAllowList {
addHost(host)
}
if len(hosts) == 0 {
for _, host := range fallbackHosts {
addHost(host)
}
}
if len(hosts) == 0 {
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
}
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: strings.TrimSpace(auto.Email),
HostPolicy: autocert.HostWhitelist(hosts...),
}
caDirURL := strings.TrimSpace(auto.CADirURL)
if caDirURL != "" {
manager.Client = &acme.Client{DirectoryURL: caDirURL}
} else {
caDirURL = autocert.DefaultACMEDirectory
}
managedHosts := make(map[string]struct{}, len(hosts))
for _, host := range hosts {
managedHosts[host] = struct{}{}
}
var warmupInProgress sync.Map
var warmupMissLogged sync.Map
manager.Cache = &autoTLSCache{
inner: autocert.DirCache(auto.CacheDir),
managedHosts: managedHosts,
pluginName: pluginName,
caDirURL: caDirURL,
warmupInProgress: &warmupInProgress,
warmupMissLogged: &warmupMissLogged,
}
cfg := manager.TLSConfig()
log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir)
var readySeen sync.Map
handleCertReady := func(host string, cert *tls.Certificate) {
var (
notAfter time.Time
hasExpiry bool
)
if t, ok := getCertificateNotAfter(cert); ok {
notAfter = t
hasExpiry = true
}
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
if hasExpiry {
if !readyLogged {
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
}
} else if !readyLogged {
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
}
}
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
if host == "" {
host = "<空SNI>"
}
cert, err := manager.GetCertificate(hello)
if err != nil {
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
return nil, err
}
handleCertReady(host, cert)
return cert, nil
}
// Warm up certificates in background after startup.
for _, host := range hosts {
h := host
go func() {
// Leave time for listener setup and route registration.
time.Sleep(1 * time.Second)
warmupMissLogged.Delete(h)
warmupInProgress.Store(h, struct{}{})
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
warmupInProgress.Delete(h)
if err != nil {
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
return
}
handleCertReady(h, cert)
}()
}
return cfg, nil
}
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
if cert == nil {
return time.Time{}, false
}
if cert.Leaf != nil {
return cert.Leaf.NotAfter, true
}
if len(cert.Certificate) == 0 {
return time.Time{}, false
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return time.Time{}, false
}
return leaf.NotAfter, true
}
type autoTLSCache struct {
inner autocert.Cache
managedHosts map[string]struct{}
pluginName string
caDirURL string
warmupInProgress *sync.Map
warmupMissLogged *sync.Map
}
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
data, err := c.inner.Get(ctx, key)
if err != autocert.ErrCacheMiss {
return data, err
}
host := strings.TrimSuffix(key, "+rsa")
if _, ok := c.managedHosts[host]; !ok {
return data, err
}
if _, warming := c.warmupInProgress.Load(host); !warming {
return data, err
}
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-01,caDirURL=%s", c.pluginName, host, c.caDirURL)
}
return data, err
}
func (c *autoTLSCache) Put(ctx context.Context, key string, data []byte) error {
return c.inner.Put(ctx, key, data)
}
func (c *autoTLSCache) Delete(ctx context.Context, key string) error {
return c.inner.Delete(ctx, key)
}
+44 -7
View File
@@ -17,9 +17,17 @@
package client
import (
"context"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -29,28 +37,57 @@ func init() {
type HTTP2HTTPPlugin struct {
opts *v1.HTTP2HTTPPluginOptions
*httpBridgePlugin
l *Listener
s *http.Server
}
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPPlugin{
opts: opts,
l: listener,
}
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
req := r.Out
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
req.URL.Scheme = "http"
req.URL.Host = p.opts.LocalAddr
if p.opts.HostHeaderRewrite != "" {
req.Host = p.opts.HostHeaderRewrite
}
for k, v := range p.opts.RequestHeaders.Set {
req.Header.Set(k, v)
}
},
nil,
)
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
p.s = &http.Server{
Handler: rp,
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
return p, nil
}
func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
_ = p.l.PutConn(wrapConn)
}
func (p *HTTP2HTTPPlugin) Name() string {
return v1.PluginHTTP2HTTP
}
func (p *HTTP2HTTPPlugin) Close() error {
return p.s.Close()
}
+44 -7
View File
@@ -17,11 +17,18 @@
package client
import (
"context"
"crypto/tls"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -31,35 +38,65 @@ func init() {
type HTTP2HTTPSPlugin struct {
opts *v1.HTTP2HTTPSPluginOptions
*httpBridgePlugin
l *Listener
s *http.Server
}
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPSPlugin{
opts: opts,
l: listener,
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"]
r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"]
req := r.Out
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
req.URL.Scheme = "https"
req.URL.Host = p.opts.LocalAddr
if p.opts.HostHeaderRewrite != "" {
req.Host = p.opts.HostHeaderRewrite
}
for k, v := range p.opts.RequestHeaders.Set {
req.Header.Set(k, v)
}
},
tr,
)
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
Transport: tr,
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
p.s = &http.Server{
Handler: rp,
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
return p, nil
}
func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
_ = p.l.PutConn(wrapConn)
}
func (p *HTTP2HTTPSPlugin) Name() string {
return v1.PluginHTTP2HTTPS
}
func (p *HTTP2HTTPSPlugin) Close() error {
return p.s.Close()
}
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 The LoliaTeam Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
}
type HTTP2HTTPSRedirectPlugin struct {
opts *v1.HTTP2HTTPSRedirectPluginOptions
l *Listener
s *http.Server
}
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPSRedirectPlugin{
opts: opts,
l: listener,
}
p.s = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Not an open redirect: the target scheme is fixed to https and the
// host is the one the client already connected to.
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound) //nolint:gosec // G710
}),
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
return p, nil
}
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
host := strings.TrimSpace(req.Host)
if host == "" {
host = strings.TrimSpace(req.URL.Host)
}
targetHost := host
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
targetHost = parsedHost
if httpsPort == 0 && parsedPort == "443" {
httpsPort = 443
}
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
}
if httpsPort != 0 && httpsPort != 443 {
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
}
return (&url.URL{
Scheme: "https",
Host: targetHost,
Path: req.URL.Path,
RawPath: req.URL.RawPath,
RawQuery: req.URL.RawQuery,
}).String()
}
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
if connInfo.SrcAddr != nil {
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
}
_ = p.l.PutConn(wrapConn)
}
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
return v1.PluginHTTP2HTTPSRedirect
}
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
return p.s.Close()
}
-143
View File
@@ -1,143 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"crypto/tls"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/plugin/client/internal/httpsserver"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
const httpBridgeReadHeaderTimeout = 60 * time.Second
func rewriteHTTPPluginRequest(
req *http.Request,
scheme string,
localAddr string,
hostHeaderRewrite string,
requestHeaders v1.HeaderOperations,
) {
req.URL.Scheme = scheme
req.URL.Host = localAddr
if hostHeaderRewrite != "" {
req.Host = hostHeaderRewrite
}
for k, v := range requestHeaders.Set {
req.Header.Set(k, v)
}
}
type httpBridgePlugin struct {
l *Listener
s *http.Server
useSourceRemoteAddr bool
}
func newHTTPBridgePluginServer(handler http.Handler, useSourceRemoteAddr bool) *httpBridgePlugin {
listener := NewProxyListener()
p := &httpBridgePlugin{
l: listener,
useSourceRemoteAddr: useSourceRemoteAddr,
}
p.s = &http.Server{
Handler: handler,
ReadHeaderTimeout: httpBridgeReadHeaderTimeout,
}
go func() {
_ = p.s.Serve(listener)
}()
return p
}
func newHTTPSBridgePluginServer(
handler http.Handler,
crtPath string,
keyPath string,
enableHTTP2 *bool,
useSourceRemoteAddr bool,
) (*httpBridgePlugin, error) {
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,
useSourceRemoteAddr: useSourceRemoteAddr,
}
go func() {
_ = p.s.ServeTLS(listener, "", "")
}()
return p
}
func newHTTPBridgeReverseProxy(
rewrite func(*httputil.ProxyRequest),
transport http.RoundTripper,
) *httputil.ReverseProxy {
rp := &httputil.ReverseProxy{
Rewrite: rewrite,
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
if transport != nil {
rp.Transport = transport
}
return rp
}
func (p *httpBridgePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
if p.useSourceRemoteAddr && connInfo.SrcAddr != nil {
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
}
_ = p.l.PutConn(wrapConn)
}
func (p *httpBridgePlugin) Close() error {
err := p.s.Close()
_ = p.l.Close()
return err
}
+38 -11
View File
@@ -45,8 +45,6 @@ type HTTPProxy struct {
s *http.Server
}
const httpProxyReadHeaderTimeout = 60 * time.Second
func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPProxyPluginOptions)
listener := NewProxyListener()
@@ -58,7 +56,7 @@ func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin
hp.s = &http.Server{
Handler: hp,
ReadHeaderTimeout: httpProxyReadHeaderTimeout,
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
@@ -75,19 +73,16 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
sc, rd := libnet.NewSharedConn(wrapConn)
firstBytes := make([]byte, len(http.MethodConnect))
_ = wrapConn.SetReadDeadline(time.Now().Add(httpProxyReadHeaderTimeout))
_, err := io.ReadFull(rd, firstBytes)
firstBytes := make([]byte, 7)
_, err := rd.Read(firstBytes)
if err != nil {
_ = wrapConn.SetReadDeadline(time.Time{})
wrapConn.Close()
return
}
if strings.EqualFold(string(firstBytes), http.MethodConnect) {
if strings.ToUpper(string(firstBytes)) == "CONNECT" {
bufRd := bufio.NewReader(sc)
request, err := http.ReadRequest(bufRd)
_ = wrapConn.SetReadDeadline(time.Time{})
if err != nil {
wrapConn.Close()
return
@@ -96,7 +91,6 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
return
}
_ = wrapConn.SetReadDeadline(time.Time{})
_ = hp.l.PutConn(sc)
}
@@ -113,7 +107,13 @@ func (hp *HTTPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
hp.HTTPHandler(rw, req)
if req.Method == http.MethodConnect {
// deprecated
// Connect request is handled in Handle function.
hp.ConnectHandler(rw, req)
} else {
hp.HTTPHandler(rw, req)
}
}
func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
@@ -135,6 +135,33 @@ func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
}
}
// deprecated
// Hijack needs to SetReadDeadline on the Conn of the request, but if we use stream compression here,
// we may always get i/o timeout error.
func (hp *HTTPProxy) ConnectHandler(rw http.ResponseWriter, req *http.Request) {
hj, ok := rw.(http.Hijacker)
if !ok {
rw.WriteHeader(http.StatusInternalServerError)
return
}
client, _, err := hj.Hijack()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
remote, err := net.Dial("tcp", req.URL.Host)
if err != nil {
http.Error(rw, "Failed", http.StatusBadRequest)
client.Close()
return
}
_, _ = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
go libio.Join(remote, client)
}
func (hp *HTTPProxy) Auth(req *http.Request) bool {
if hp.opts.HTTPUser == "" && hp.opts.HTTPPassword == "" {
return true
-107
View File
@@ -1,107 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"bufio"
"context"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestHTTPProxyHandleFragmentedConnectMethod(t *testing.T) {
require := require.New(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(err)
defer ln.Close()
const payload = "ping"
echoErr := make(chan error, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
echoErr <- err
return
}
defer conn.Close()
buf := make([]byte, len(payload))
if _, err = io.ReadFull(conn, buf); err != nil {
echoErr <- err
return
}
if string(buf) != payload {
echoErr <- fmt.Errorf("unexpected payload %q", string(buf))
return
}
_, err = conn.Write([]byte("echo:" + payload))
echoErr <- err
}()
hp := &HTTPProxy{
opts: &v1.HTTPProxyPluginOptions{},
l: NewProxyListener(),
}
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
go hp.Handle(context.Background(), &ConnectionInfo{
Conn: serverConn,
UnderlyingConn: serverConn,
})
require.NoError(clientConn.SetDeadline(time.Now().Add(5 * time.Second)))
targetAddr := ln.Addr().String()
req := "CONNECT " + targetAddr + " HTTP/1.1\r\nHost: " + targetAddr + "\r\n\r\n"
_, err = clientConn.Write([]byte("CON"))
require.NoError(err)
_, err = clientConn.Write([]byte(req[len("CON"):]))
require.NoError(err)
rd := bufio.NewReader(clientConn)
status, err := rd.ReadString('\n')
require.NoError(err)
require.Equal("HTTP/1.1 200 OK\r\n", status)
line, err := rd.ReadString('\n')
require.NoError(err)
require.Equal("\r\n", line)
_, err = clientConn.Write([]byte(payload))
require.NoError(err)
got := make([]byte, len("echo:"+payload))
_, err = io.ReadFull(rd, got)
require.NoError(err)
require.Equal("echo:"+payload, string(got))
select {
case err := <-echoErr:
require.NoError(err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for echo server")
}
}
+66 -18
View File
@@ -17,10 +17,22 @@
package client
import (
"context"
"crypto/tls"
"fmt"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/transport"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -30,44 +42,80 @@ func init() {
type HTTPS2HTTPPlugin struct {
opts *v1.HTTPS2HTTPPluginOptions
*httpBridgePlugin
l *Listener
s *http.Server
}
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPPluginOptions)
listener := NewProxyListener()
p := &HTTPS2HTTPPlugin{
opts: opts,
l: listener,
}
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.SetXForwarded()
req := r.Out
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
req.URL.Scheme = "http"
req.URL.Host = p.opts.LocalAddr
if p.opts.HostHeaderRewrite != "" {
req.Host = p.opts.HostHeaderRewrite
}
for k, v := range p.opts.RequestHeaders.Set {
req.Header.Set(k, v)
}
},
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)
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
host, _ := httppkg.CanonicalHost(r.Host)
if tlsServerName != "" && tlsServerName != host {
w.WriteHeader(http.StatusMisdirectedRequest)
return
}
}
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
return p, nil
}
rp.ServeHTTP(w, r)
})
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
return nil, fmt.Errorf("gen TLS config error: %v", err)
}
p.httpBridgePlugin = server
p.s = &http.Server{
Handler: handler,
ReadHeaderTimeout: 60 * time.Second,
TLSConfig: tlsConfig,
}
if !lo.FromPtr(opts.EnableHTTP2) {
p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
}
go func() {
_ = p.s.ServeTLS(listener, "", "")
}()
return p, nil
}
func (p *HTTPS2HTTPPlugin) 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 *HTTPS2HTTPPlugin) Name() string {
return v1.PluginHTTPS2HTTP
}
func (p *HTTPS2HTTPPlugin) Close() error {
return p.s.Close()
}
+66 -18
View File
@@ -17,12 +17,22 @@
package client
import (
"context"
"crypto/tls"
"fmt"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/transport"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -32,48 +42,86 @@ func init() {
type HTTPS2HTTPSPlugin struct {
opts *v1.HTTPS2HTTPSPluginOptions
*httpBridgePlugin
l *Listener
s *http.Server
}
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
listener := NewProxyListener()
p := &HTTPS2HTTPSPlugin{
opts: opts,
l: listener,
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.SetXForwarded()
req := r.Out
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
req.URL.Scheme = "https"
req.URL.Host = p.opts.LocalAddr
if p.opts.HostHeaderRewrite != "" {
req.Host = p.opts.HostHeaderRewrite
}
for k, v := range p.opts.RequestHeaders.Set {
req.Header.Set(k, v)
}
},
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)
Transport: tr,
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
host, _ := httppkg.CanonicalHost(r.Host)
if tlsServerName != "" && tlsServerName != host {
w.WriteHeader(http.StatusMisdirectedRequest)
return
}
}
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
return p, nil
}
rp.ServeHTTP(w, r)
})
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
return nil, fmt.Errorf("gen TLS config error: %v", err)
}
p.httpBridgePlugin = server
p.s = &http.Server{
Handler: handler,
ReadHeaderTimeout: 60 * time.Second,
TLSConfig: tlsConfig,
}
if !lo.FromPtr(opts.EnableHTTP2) {
p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
}
go func() {
_ = p.s.ServeTLS(listener, "", "")
}()
return p, nil
}
func (p *HTTPS2HTTPSPlugin) 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 *HTTPS2HTTPSPlugin) Name() string {
return v1.PluginHTTPS2HTTPS
}
func (p *HTTPS2HTTPSPlugin) Close() error {
return p.s.Close()
}
@@ -1,65 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package httpsserver
import (
"crypto/tls"
"fmt"
"net/http"
"time"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/transport"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
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,
TLSConfig: tlsConfig,
}
if !lo.FromPtr(enableHTTP2) {
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
}
return server
}
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
host, _ := httppkg.CanonicalHost(r.Host)
if tlsServerName != "" && tlsServerName != host {
w.WriteHeader(http.StatusMisdirectedRequest)
return
}
}
handler.ServeHTTP(w, r)
})
}
-1
View File
@@ -30,7 +30,6 @@ import (
type PluginContext struct {
Name string
HostAllowList []string
VnetController *vnet.Controller
}

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