forked from Mxmilu666/frp
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92b4df6dd9
|
||
|
|
10c313daa6
|
||
|
|
71a2bf30f9 | ||
|
|
a6a782bed4 | ||
|
|
223b44336c | ||
|
|
f6688e2a0d | ||
|
|
f666d97b64 | ||
|
|
5b68148f11 | ||
|
|
d1928f9689 | ||
|
|
1a3a872bd2 | ||
|
|
290017cb53 | ||
|
|
2291e8835f | ||
|
|
1ab59e763c | ||
|
|
9d45a55720 | ||
|
|
effa496859 | ||
|
|
5c6d761c12 | ||
|
|
3d89ab7ff1 | ||
|
|
f3828cbfdb | ||
|
|
00c24e39bb | ||
|
|
2175557d48 | ||
|
|
de4b483710 | ||
|
|
2d63f6b1f9 | ||
|
|
40274d8c92 | ||
|
|
18eef83b69 | ||
|
|
466a94f5d1 | ||
|
|
898bcf73d5 | ||
|
|
0e53833a2c | ||
|
|
c23934bb67 | ||
|
|
23512f577e | ||
|
|
84b907a198 | ||
|
|
f8fc3c6b1b | ||
|
|
7dc7be930e | ||
|
|
d486018885 | ||
|
|
fe79598ee4 | ||
|
|
269a26c5d5 | ||
|
|
0ff5557571
|
||
|
|
2886393f5b | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
68509f5d44 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
bce3d5a3d8
|
||
|
|
33a29b04e4
|
||
|
|
481ccad05e
|
||
|
|
521542b796
|
||
|
|
7fe152e3aa | ||
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
e487e7f96f
|
||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
2f73a38a2f
|
||
|
|
a9eb5e6901
|
||
|
|
386df7f426
|
||
|
|
79d5cc00ab
|
||
|
|
d43a4e8950
|
||
|
|
d205f2bb35
|
||
|
|
a2b9062f2c
|
||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 | ||
|
|
26f3af3fdd
|
||
|
|
7d79b83a39
|
||
|
|
e3ea471e44
|
||
|
|
e66f45d8be
|
||
|
|
cc0b8d0f94
|
||
|
|
27237542c8
|
||
|
|
406ea5ebee
|
||
|
|
81a92d19d5
|
||
|
|
83847f32ed
|
||
|
|
1f2c26761d
|
||
|
|
c836e276a7
|
||
|
|
d16f07d3e8 | ||
|
|
6bd5eb92c3
|
||
|
|
09602f5d74
|
||
|
|
d7559b39e2
|
||
|
|
72d147dfa5
|
||
|
|
481121a6c2
|
||
|
|
be252de683
|
||
|
|
0a99c1071b
|
||
|
|
dd37b2e199
|
||
|
|
803e548f42
|
||
|
|
2dac44ac2e
|
||
|
|
655dc3cb2a
|
||
|
|
9894342f46
|
||
|
|
e7cc706c86
|
||
|
|
92ac2b9153
|
||
|
|
1ed369e962
|
||
|
|
b74a8d0232
|
||
|
|
d2180081a0
|
||
|
|
51f4e065b5
|
||
|
|
e58f774086
|
||
|
|
178e381a26
|
||
|
|
26b93ae3a3
|
||
|
|
a2aeee28e4
|
||
|
|
0416caef71
|
||
|
|
1004473e42
|
||
|
|
f386996928
|
||
|
|
4eb4b202c5
|
||
|
|
ac5bdad507
|
||
|
|
42f4ea7f87
|
||
|
|
36e5ac094b
|
||
|
|
72f79d3357
|
||
|
|
e1f905f63f
|
||
|
|
eb58f09268
|
||
|
|
46955ffc80
|
||
|
|
2d63296576
|
||
|
|
a76ba823ee
|
@@ -7,14 +7,15 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Build web assets (frps)
|
||||
command: make install build
|
||||
working_directory: web/frps
|
||||
name: Test and build web assets
|
||||
command: make web-ci
|
||||
- run:
|
||||
name: Build web assets (frpc)
|
||||
command: make install build
|
||||
working_directory: web/frpc
|
||||
- run: make
|
||||
name: Check Go formatting and build binaries
|
||||
command: |
|
||||
set -e
|
||||
make env fmt
|
||||
git diff --exit-code
|
||||
make build
|
||||
- run: make alltest
|
||||
|
||||
workflows:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [fatedier]
|
||||
custom: ["https://afdian.com/a/fatedier"]
|
||||
@@ -1,3 +0,0 @@
|
||||
### WHY
|
||||
|
||||
<!-- author to complete -->
|
||||
@@ -0,0 +1,194 @@
|
||||
name: Build FRP Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, windows, darwin, freebsd, openbsd, android]
|
||||
goarch: [amd64, 386, arm, arm64]
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
exclude:
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: 386
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
- goos: android
|
||||
goarch: amd64
|
||||
- goos: android
|
||||
goarch: 386
|
||||
# 排除 Android ARM 32位,在单独的 job 中处理
|
||||
- goos: android
|
||||
goarch: arm
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y zip tar make gcc g++ upx
|
||||
|
||||
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
run: |
|
||||
mkdir -p release/packages
|
||||
|
||||
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
|
||||
# 构建版本号
|
||||
make
|
||||
version=$(./bin/frps --version)
|
||||
echo "Detected version: $version"
|
||||
|
||||
export GOOS=${{ matrix.goos }}
|
||||
export GOARCH=${{ matrix.goarch }}
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# 构建可执行文件
|
||||
make frpc frps
|
||||
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
|
||||
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
|
||||
fi
|
||||
|
||||
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
mv ./bin/frpc.exe "$out_dir/frpc.exe"
|
||||
mv ./bin/frps.exe "$out_dir/frps.exe"
|
||||
else
|
||||
mv ./bin/frpc "$out_dir/frpc"
|
||||
mv ./bin/frps "$out_dir/frps"
|
||||
fi
|
||||
|
||||
cp LICENSE "$out_dir"
|
||||
cp -f conf/frpc.toml "$out_dir"
|
||||
cp -f conf/frps.toml "$out_dir"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
path: |
|
||||
release/packages/frp_*
|
||||
retention-days: 7
|
||||
|
||||
build-android-arm:
|
||||
name: Build FRP android-arm
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Install dependencies and Android NDK
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
|
||||
|
||||
# 下载并安装 Android NDK
|
||||
echo "Downloading Android NDK..."
|
||||
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
|
||||
echo "Extracting Android NDK..."
|
||||
unzip -q android-ndk-r26c-linux.zip
|
||||
echo "NDK installed at: $PWD/android-ndk-r26c"
|
||||
|
||||
- name: Build FRP for android-arm
|
||||
run: |
|
||||
mkdir -p release/packages
|
||||
mkdir -p bin
|
||||
|
||||
echo "Building for android-arm with CGO"
|
||||
|
||||
# 首先构建一次获取版本号
|
||||
CGO_ENABLED=0 make
|
||||
version=$(./bin/frps --version)
|
||||
echo "Detected version: $version"
|
||||
|
||||
# 清理之前的构建
|
||||
rm -rf ./bin/*
|
||||
|
||||
# 设置 Android ARM 交叉编译环境
|
||||
export GOOS=android
|
||||
export GOARCH=arm
|
||||
export GOARM=7
|
||||
export CGO_ENABLED=1
|
||||
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
|
||||
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
|
||||
|
||||
echo "Environment:"
|
||||
echo "GOOS=$GOOS"
|
||||
echo "GOARCH=$GOARCH"
|
||||
echo "GOARM=$GOARM"
|
||||
echo "CGO_ENABLED=$CGO_ENABLED"
|
||||
echo "CC=$CC"
|
||||
|
||||
# 直接使用 go build 命令,不通过 Makefile,防止 CGO_ENABLED 被覆盖
|
||||
# -checklinkname=0: 关闭 Go 1.23+ 的 linkname 检查,规避 wlynxg/anet
|
||||
# 在 android 下通过 //go:linkname 引用 net.zoneCache 导致的链接失败
|
||||
echo "Building frps..."
|
||||
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frps -o bin/frps ./cmd/frps
|
||||
|
||||
echo "Building frpc..."
|
||||
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frpc -o bin/frpc ./cmd/frpc
|
||||
|
||||
# 验证文件已生成
|
||||
ls -lh ./bin/
|
||||
file ./bin/frpc
|
||||
file ./bin/frps
|
||||
|
||||
out_dir="release/packages/frp_${version}_android_arm"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
mv ./bin/frpc "$out_dir/frpc"
|
||||
mv ./bin/frps "$out_dir/frps"
|
||||
|
||||
cp LICENSE "$out_dir"
|
||||
cp -f conf/frpc.toml "$out_dir"
|
||||
cp -f conf/frps.toml "$out_dir"
|
||||
|
||||
echo "Build completed for android-arm"
|
||||
ls -lh "$out_dir"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: LoliaFrp_android_arm
|
||||
path: |
|
||||
release/packages/frp_*
|
||||
retention-days: 7
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Build Image and Publish to Dockerhub & GPR
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Image tag'
|
||||
required: true
|
||||
default: 'test'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build Image from Dockerfile and binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# environment
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# get image tag name
|
||||
- name: Get Image Tag Name
|
||||
run: |
|
||||
if [ x${{ github.event.inputs.tag }} == x"" ]; then
|
||||
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to the GPR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GPR_TOKEN }}
|
||||
|
||||
# prepare image tags
|
||||
- name: Prepare Image Tags
|
||||
run: |
|
||||
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
|
||||
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
|
||||
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push frpc
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frpc
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,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 }}
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: frps
|
||||
dockerfile: dockerfiles/Dockerfile-for-frps
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
|
||||
- name: frpc
|
||||
dockerfile: dockerfiles/Dockerfile-for-frpc
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ matrix.image_name }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
||||
- name: Generate image digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "Image pushed to: ${{ matrix.image_name }}"
|
||||
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
||||
@@ -22,12 +22,8 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
run: make build
|
||||
working-directory: web/frps
|
||||
- name: Build web assets (frpc)
|
||||
run: make build
|
||||
working-directory: web/frpc
|
||||
- name: Test and build web assets
|
||||
run: make web-ci
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Release FRP Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to release (e.g., v1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# 调用 build-all workflow
|
||||
build:
|
||||
uses: ./.github/workflows/build-all.yaml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# 创建 release
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display artifact structure
|
||||
run: |
|
||||
echo "Artifact structure:"
|
||||
ls -R artifacts/
|
||||
|
||||
- name: Organize release files
|
||||
run: |
|
||||
mkdir -p release_files
|
||||
|
||||
# 查找并复制所有压缩包
|
||||
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
|
||||
|
||||
# 如果没有压缩包,尝试查找二进制文件并打包
|
||||
if [ -z "$(ls -A release_files/)" ]; then
|
||||
echo "No archives found, looking for directories to package..."
|
||||
for dir in artifacts/*/; do
|
||||
if [ -d "$dir" ]; then
|
||||
artifact_name=$(basename "$dir")
|
||||
echo "Packaging $artifact_name"
|
||||
|
||||
# 检查是否是 Windows 构建
|
||||
if echo "$artifact_name" | grep -q "windows"; then
|
||||
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
|
||||
else
|
||||
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Files in release_files:"
|
||||
ls -lh release_files/
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release_files
|
||||
if [ -n "$(ls -A .)" ]; then
|
||||
sha256sum * > sha256sum.txt
|
||||
cat sha256sum.txt
|
||||
else
|
||||
echo "No files to generate checksums for!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Debug - Check tags and commits
|
||||
run: |
|
||||
echo "Current tag: ${{ steps.tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
|
||||
echo "Previous tag: $PREV_TAG"
|
||||
|
||||
echo ""
|
||||
echo "Commits between tags:"
|
||||
if [ "$PREV_TAG" != "none" ]; then
|
||||
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
|
||||
else
|
||||
echo "First tag, showing all commits:"
|
||||
git log --oneline ${{ steps.tag.outputs.tag }}
|
||||
fi
|
||||
|
||||
- name: Build Changelog
|
||||
id: changelog
|
||||
uses: requarks/changelog-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
writeToFile: false
|
||||
includeInvalidCommits: true
|
||||
useGitmojis: false
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: Release ${{ steps.tag.outputs.tag }}
|
||||
body: ${{ steps.changelog.outputs.changes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
release_files/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -32,6 +32,10 @@ node_modules/
|
||||
*.swp
|
||||
|
||||
# AI
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.superpowers/
|
||||
|
||||
# TLS
|
||||
.autotls-cache
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export PATH := $(PATH):`go env GOPATH`/bin
|
||||
export GO111MODULE=on
|
||||
LDFLAGS := -s -w
|
||||
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep),
|
||||
# which uses //go:linkname to reference net.zoneCache on android targets.
|
||||
LDFLAGS := -s -w -checklinkname=0
|
||||
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
||||
FRP_COMPAT_BASELINE_COUNT ?= 8
|
||||
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
|
||||
|
||||
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
|
||||
.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
|
||||
|
||||
all: env fmt web build
|
||||
|
||||
@@ -16,6 +18,9 @@ env:
|
||||
|
||||
web: frps-web frpc-web
|
||||
|
||||
web-ci:
|
||||
cd web && npm ci && npm run lint:check --workspace frps && npm run lint:check --workspace frpc && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc
|
||||
|
||||
frps-web:
|
||||
$(MAKE) -C web/frps build
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ all: build
|
||||
build: app
|
||||
|
||||
app:
|
||||
@$(foreach n, $(os-archs), \
|
||||
@set -e; $(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); \
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
[](https://circleci.com/gh/fatedier/frp)
|
||||
[](https://github.com/fatedier/frp/releases)
|
||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||
|
||||
[README](README.md) | [中文文档](README_zh.md)
|
||||
@@ -13,14 +12,6 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
<br>
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||
@@ -40,6 +31,14 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<br>
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## What is frp?
|
||||
|
||||
+8
-9
@@ -2,7 +2,6 @@
|
||||
|
||||
[](https://circleci.com/gh/fatedier/frp)
|
||||
[](https://github.com/fatedier/frp/releases)
|
||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||
|
||||
[README](README.md) | [中文文档](README_zh.md)
|
||||
@@ -15,14 +14,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<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">
|
||||
<br>
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||
@@ -42,6 +33,14 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<br>
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
+7
-19
@@ -1,21 +1,9 @@
|
||||
## Compatibility Policy
|
||||
|
||||
Starting with v0.69.0, each minor release is supported until there are nine newer minor releases. For example, v0.69.0 will be supported until v0.78.0 is released. Within this window, frpc v0.69.0 is guaranteed to work with any frps from v0.61.0 to v0.77.0, and vice versa. Patch releases within the same minor are always compatible. Versions outside the support window may continue to work on a best-effort basis, but compatibility is no longer guaranteed.
|
||||
|
||||
For mixed-version deployments, upgrade frps first, then upgrade frpc. This keeps the server side ready for newer client-side protocol behavior before clients start using it.
|
||||
|
||||
## Notes
|
||||
|
||||
This release introduces wire protocol v2 as a transition path for future frpc/frps protocol changes. The existing wire protocol is difficult to extend without compatibility risk, and upcoming changes, including replacing deprecated stream encryption methods, require a versioned protocol.
|
||||
|
||||
**The default value of `transport.wireProtocol` remains `v1` in this release.** Users can keep the default for now. To test v2 early, upgrade both frpc and frps to versions that support it, then set `transport.wireProtocol = "v2"` in frpc. A v2-enabled frpc cannot connect to an older frps.
|
||||
|
||||
When `transport.wireProtocol = "v2"` is enabled, the control channel uses negotiated AEAD encryption after the login handshake. Both frpc and frps must be upgraded to this release to use v2.
|
||||
|
||||
v1 will be deprecated when v2 becomes the default in a future release. It will continue to be supported until v0.78.0 is released, and may be removed in v0.78.0 or later.
|
||||
|
||||
## Features
|
||||
|
||||
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
||||
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
|
||||
* Wire protocol v2 now negotiates AEAD control-channel encryption. Supported algorithms are `xchacha20-poly1305` and `aes-256-gcm`; frpc advertises its preferred order based on local AES-GCM hardware support, and frps selects the first supported algorithm from that list.
|
||||
* 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.
|
||||
|
||||
@@ -2,12 +2,16 @@ package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fatedier/frp/client/configmgmt"
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
"github.com/fatedier/frp/pkg/vnet"
|
||||
)
|
||||
|
||||
func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
|
||||
@@ -22,6 +26,256 @@ func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestVirtualNetProxyConfig(name string) *v1.STCPProxyConfig {
|
||||
return &v1.STCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||
Name: name,
|
||||
Type: "stcp",
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
Plugin: v1.TypedClientPluginOptions{
|
||||
Type: v1.PluginVirtualNet,
|
||||
ClientPluginOptions: &v1.VirtualNetPluginOptions{Type: v1.PluginVirtualNet},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestVirtualNetVisitorConfig(name string) *v1.STCPVisitorConfig {
|
||||
return &v1.STCPVisitorConfig{
|
||||
VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||
Name: name,
|
||||
Type: "stcp",
|
||||
ServerName: "vnet-server",
|
||||
SecretKey: "secret",
|
||||
BindPort: -1,
|
||||
Plugin: v1.TypedVisitorPluginOptions{
|
||||
Type: v1.VisitorPluginVirtualNet,
|
||||
VisitorPluginOptions: &v1.VirtualNetVisitorPluginOptions{
|
||||
Type: v1.VisitorPluginVirtualNet,
|
||||
DestinationIP: "100.86.0.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConfigManagerReloadVirtualNetRuntimeDependency(t *testing.T) {
|
||||
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startupVirtualNetAddr string
|
||||
nextConfig string
|
||||
wantRuntimeDependency bool
|
||||
}{
|
||||
{
|
||||
name: "unrelated common config",
|
||||
nextConfig: `serverAddr = "0.0.0.0"`,
|
||||
},
|
||||
{
|
||||
name: "VirtualNet address without startup runtime",
|
||||
nextConfig: `featureGates = { VirtualNet = true }
|
||||
virtualNet.address = "100.86.0.4/24"
|
||||
`,
|
||||
wantRuntimeDependency: true,
|
||||
},
|
||||
{
|
||||
name: "VirtualNet proxy without startup runtime",
|
||||
nextConfig: `[[proxies]]
|
||||
name = "vnet-proxy"
|
||||
type = "stcp"
|
||||
secretKey = "secret"
|
||||
[proxies.plugin]
|
||||
type = "virtual_net"
|
||||
`,
|
||||
wantRuntimeDependency: true,
|
||||
},
|
||||
{
|
||||
name: "VirtualNet visitor without startup runtime",
|
||||
nextConfig: `[[visitors]]
|
||||
name = "vnet-visitor"
|
||||
type = "stcp"
|
||||
serverName = "vnet-server"
|
||||
secretKey = "secret"
|
||||
bindPort = -1
|
||||
[visitors.plugin]
|
||||
type = "virtual_net"
|
||||
destinationIP = "100.86.0.1"
|
||||
`,
|
||||
wantRuntimeDependency: true,
|
||||
},
|
||||
{
|
||||
name: "existing VirtualNet startup runtime",
|
||||
startupVirtualNetAddr: "100.86.0.4/24",
|
||||
nextConfig: `featureGates = { VirtualNet = true }
|
||||
virtualNet.address = "100.86.0.5/24"
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
current := &v1.ClientCommonConfig{}
|
||||
if tc.startupVirtualNetAddr != "" {
|
||||
current.FeatureGates = map[string]bool{"VirtualNet": true}
|
||||
current.VirtualNet.Address = tc.startupVirtualNetAddr
|
||||
}
|
||||
if err := current.Complete(); err != nil {
|
||||
t.Fatalf("complete current config: %v", err)
|
||||
}
|
||||
|
||||
configFile := filepath.Join(t.TempDir(), "frpc.toml")
|
||||
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
configSource := source.NewConfigSource()
|
||||
aggregator := source.NewAggregator(configSource)
|
||||
svr := &Service{
|
||||
common: current,
|
||||
reloadCommon: current,
|
||||
configFilePath: configFile,
|
||||
unsafeFeatures: security.NewUnsafeFeatures(nil),
|
||||
aggregator: aggregator,
|
||||
configSource: configSource,
|
||||
}
|
||||
if tc.startupVirtualNetAddr != "" {
|
||||
svr.vnetController = vnet.NewController(current.VirtualNet)
|
||||
}
|
||||
|
||||
err := (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
|
||||
if tc.wantRuntimeDependency {
|
||||
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
|
||||
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if svr.common != current {
|
||||
t.Fatal("reload should not replace startup common config")
|
||||
}
|
||||
if tc.startupVirtualNetAddr == "" && svr.vnetController != nil {
|
||||
t.Fatal("reload should not enable startup-only VirtualNet runtime state")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConfigManagerReloadVirtualNetRuntimeDependencyUsesMergedSources(t *testing.T) {
|
||||
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nextConfig string
|
||||
storeProxy v1.ProxyConfigurer
|
||||
storeVisitor v1.VisitorConfigurer
|
||||
wantRuntimeDependency bool
|
||||
wantProxyPlugin string
|
||||
}{
|
||||
{
|
||||
name: "Store VirtualNet proxy is rejected",
|
||||
nextConfig: `serverAddr = "0.0.0.0"`,
|
||||
storeProxy: newTestVirtualNetProxyConfig("store-vnet"),
|
||||
wantRuntimeDependency: true,
|
||||
},
|
||||
{
|
||||
name: "Store VirtualNet visitor is rejected",
|
||||
nextConfig: `serverAddr = "0.0.0.0"`,
|
||||
storeVisitor: newTestVirtualNetVisitorConfig("store-vnet"),
|
||||
wantRuntimeDependency: true,
|
||||
},
|
||||
{
|
||||
name: "Store VirtualNet proxy overrides file proxy",
|
||||
nextConfig: `[[proxies]]
|
||||
name = "shared"
|
||||
type = "tcp"
|
||||
localPort = 10080
|
||||
remotePort = 10081
|
||||
`,
|
||||
storeProxy: newTestVirtualNetProxyConfig("shared"),
|
||||
wantRuntimeDependency: true,
|
||||
},
|
||||
{
|
||||
name: "Store non-VirtualNet proxy overrides file VirtualNet proxy",
|
||||
nextConfig: `[[proxies]]
|
||||
name = "shared"
|
||||
type = "stcp"
|
||||
secretKey = "secret"
|
||||
[proxies.plugin]
|
||||
type = "virtual_net"
|
||||
`,
|
||||
storeProxy: newTestRawTCPProxyConfig("shared"),
|
||||
wantProxyPlugin: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
current := &v1.ClientCommonConfig{}
|
||||
if err := current.Complete(); err != nil {
|
||||
t.Fatalf("complete current config: %v", err)
|
||||
}
|
||||
|
||||
configFile := filepath.Join(t.TempDir(), "frpc.toml")
|
||||
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
|
||||
Path: filepath.Join(t.TempDir(), "store.json"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new store source: %v", err)
|
||||
}
|
||||
if tc.storeProxy != nil {
|
||||
if err := storeSource.AddProxy(tc.storeProxy); err != nil {
|
||||
t.Fatalf("add store proxy: %v", err)
|
||||
}
|
||||
}
|
||||
if tc.storeVisitor != nil {
|
||||
if err := storeSource.AddVisitor(tc.storeVisitor); err != nil {
|
||||
t.Fatalf("add store visitor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
configSource := source.NewConfigSource()
|
||||
aggregator := source.NewAggregator(configSource)
|
||||
aggregator.SetStoreSource(storeSource)
|
||||
svr := &Service{
|
||||
common: current,
|
||||
reloadCommon: current,
|
||||
configFilePath: configFile,
|
||||
unsafeFeatures: security.NewUnsafeFeatures(nil),
|
||||
aggregator: aggregator,
|
||||
configSource: configSource,
|
||||
storeSource: storeSource,
|
||||
}
|
||||
|
||||
err = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
|
||||
if tc.wantRuntimeDependency {
|
||||
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
|
||||
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
|
||||
if len(svr.proxyCfgs) != 1 {
|
||||
t.Fatalf("expected one applied proxy, got %d", len(svr.proxyCfgs))
|
||||
}
|
||||
if got := svr.proxyCfgs[0].GetBaseConfig().Plugin.Type; got != tc.wantProxyPlugin {
|
||||
t.Fatalf("unexpected applied proxy plugin: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
|
||||
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
|
||||
Path: filepath.Join(t.TempDir(), "store.json"),
|
||||
|
||||
+50
-4
@@ -16,7 +16,9 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +49,8 @@ type SessionContext struct {
|
||||
Connector MessageConnector
|
||||
// Virtual net controller
|
||||
VnetController *vnet.Controller
|
||||
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
|
||||
UDPPacketCodec string
|
||||
}
|
||||
|
||||
type Control struct {
|
||||
@@ -92,9 +96,16 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
||||
ctl.registerMsgHandlers()
|
||||
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
|
||||
|
||||
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
|
||||
ctl.pm = proxy.NewManager(
|
||||
ctl.ctx,
|
||||
sessionCtx.Common,
|
||||
sessionCtx.Auth.EncryptionKey(),
|
||||
ctl.msgTransporter,
|
||||
sessionCtx.VnetController,
|
||||
sessionCtx.UDPPacketCodec,
|
||||
)
|
||||
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
|
||||
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
|
||||
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController, sessionCtx.UDPPacketCodec)
|
||||
return ctl, nil
|
||||
}
|
||||
|
||||
@@ -160,9 +171,44 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
|
||||
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
||||
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
||||
if err != nil {
|
||||
xl.Warnf("[%s] start error: %v", proxyName, err)
|
||||
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
|
||||
} else {
|
||||
xl.Infof("[%s] start proxy success", proxyName)
|
||||
xl.Infof("[%s] 成功启动隧道", proxyName)
|
||||
if inMsg.RemoteAddr != "" {
|
||||
// Get proxy type to format access message
|
||||
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
|
||||
proxyType := status.Type
|
||||
remoteAddr := inMsg.RemoteAddr
|
||||
var accessMsg string
|
||||
|
||||
switch proxyType {
|
||||
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
|
||||
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
|
||||
if strings.HasPrefix(remoteAddr, ":") {
|
||||
serverAddr := ctl.sessionCtx.Common.ServerAddr
|
||||
remoteAddr = serverAddr + remoteAddr
|
||||
}
|
||||
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
||||
case "http", "https":
|
||||
// Format as URL with protocol
|
||||
protocol := proxyType
|
||||
addr := remoteAddr
|
||||
// Remove standard ports for cleaner URL
|
||||
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
|
||||
addr = strings.TrimSuffix(addr, ":80")
|
||||
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
|
||||
addr = strings.TrimSuffix(addr, ":443")
|
||||
}
|
||||
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
|
||||
default:
|
||||
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
||||
}
|
||||
|
||||
xl.Infof("[%s] %s", proxyName, accessMsg)
|
||||
} else {
|
||||
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro
|
||||
Auth: d.auth,
|
||||
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
|
||||
VnetController: d.vnetController,
|
||||
UDPPacketCodec: loginResult.udpPacketCodec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -127,8 +128,9 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
|
||||
}
|
||||
|
||||
type loginExchangeResult struct {
|
||||
resp *msg.LoginResp
|
||||
crypto *wire.CryptoContext
|
||||
resp *msg.LoginResp
|
||||
crypto *wire.CryptoContext
|
||||
udpPacketCodec string
|
||||
}
|
||||
|
||||
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
|
||||
@@ -172,6 +174,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
||||
}()
|
||||
|
||||
var cryptoContext *wire.CryptoContext
|
||||
var udpPacketCodec string
|
||||
if wireConn != nil {
|
||||
serverHelloFrame, err := wireConn.ReadFrame()
|
||||
if err != nil {
|
||||
@@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
|
||||
}
|
||||
|
||||
var loginRespMsg msg.LoginResp
|
||||
@@ -198,8 +202,9 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
||||
return nil, err
|
||||
}
|
||||
return &loginExchangeResult{
|
||||
resp: &loginRespMsg,
|
||||
crypto: cryptoContext,
|
||||
resp: &loginRespMsg,
|
||||
crypto: cryptoContext,
|
||||
udpPacketCodec: udpPacketCodec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) {
|
||||
defer sessionCtx.Connector.Close()
|
||||
|
||||
require.Equal(t, "run-v1", sessionCtx.RunID)
|
||||
require.Empty(t, sessionCtx.UDPPacketCodec)
|
||||
require.NotNil(t, sessionCtx.Conn)
|
||||
require.NotNil(t, sessionCtx.Connector)
|
||||
require.False(t, connector.closed.Load())
|
||||
@@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
|
||||
defer sessionCtx.Connector.Close()
|
||||
|
||||
require.Equal(t, "run-v2", sessionCtx.RunID)
|
||||
require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec)
|
||||
require.NotNil(t, sessionCtx.Conn)
|
||||
require.NotNil(t, sessionCtx.Connector)
|
||||
require.False(t, connector.closed.Load())
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !frps
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
clientproxy "github.com/fatedier/frp/client/proxy"
|
||||
"github.com/fatedier/frp/pkg/auth"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestControlPropagatesBinaryUDPPacketCodecToWorkConn(t *testing.T) {
|
||||
echoConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = echoConn.Close() })
|
||||
|
||||
echoDone := make(chan error, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 64)
|
||||
n, addr, err := echoConn.ReadFromUDP(buf)
|
||||
if err == nil {
|
||||
_, err = echoConn.WriteToUDP(buf[:n], addr)
|
||||
}
|
||||
echoDone <- err
|
||||
}()
|
||||
|
||||
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
|
||||
Method: v1.AuthMethodToken,
|
||||
Token: "token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
controlConn, controlPeer := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = controlConn.Close()
|
||||
_ = controlPeer.Close()
|
||||
})
|
||||
common := &v1.ClientCommonConfig{
|
||||
Transport: v1.ClientTransportConfig{WireProtocol: wire.ProtocolV2},
|
||||
UDPPacketSize: 1500,
|
||||
}
|
||||
ctl, err := NewControl(context.Background(), &SessionContext{
|
||||
Common: common,
|
||||
RunID: "binary-udp-test",
|
||||
Conn: msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)),
|
||||
Auth: authRuntime,
|
||||
UDPPacketCodec: wire.UDPPacketCodecBinary,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(ctl.pm.Close)
|
||||
|
||||
echoAddr := echoConn.LocalAddr().(*net.UDPAddr)
|
||||
proxyCfg := &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||
Name: "udp",
|
||||
Type: string(v1.ProxyTypeUDP),
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
LocalIP: "127.0.0.1",
|
||||
LocalPort: echoAddr.Port,
|
||||
},
|
||||
},
|
||||
}
|
||||
ctl.pm.UpdateAll([]v1.ProxyConfigurer{proxyCfg})
|
||||
require.Eventually(t, func() bool {
|
||||
status, ok := ctl.pm.GetProxyStatus("udp")
|
||||
return ok && status.Phase == clientproxy.ProxyPhaseWaitStart
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.NoError(t, ctl.pm.StartProxy("udp", "", ""))
|
||||
|
||||
workClient, workServer := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = workClient.Close()
|
||||
_ = workServer.Close()
|
||||
})
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
require.NoError(t, workClient.SetDeadline(deadline))
|
||||
require.NoError(t, workServer.SetDeadline(deadline))
|
||||
ctl.pm.HandleWorkConn("udp", workClient, &msg.StartWorkConn{ProxyName: "udp"})
|
||||
|
||||
serverRW, err := msg.NewUDPPacketReadWriter(workServer, wire.ProtocolV2, wire.UDPPacketCodecBinary)
|
||||
require.NoError(t, err)
|
||||
writeDone := make(chan error, 1)
|
||||
in := &msg.UDPPacket{
|
||||
Content: []byte("binary udp"),
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
|
||||
}
|
||||
go func() {
|
||||
writeDone <- serverRW.WriteMsg(in)
|
||||
}()
|
||||
|
||||
frame, err := wire.NewConn(workServer).ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||
require.GreaterOrEqual(t, len(frame.Payload), 2)
|
||||
require.Equal(t, msg.V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
out, err := msg.DecodeUDPPacketBinary(frame.Payload[2:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, in.Content, out.Content)
|
||||
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
|
||||
require.NoError(t, <-writeDone)
|
||||
require.NoError(t, <-echoDone)
|
||||
}
|
||||
+65
-9
@@ -20,7 +20,9 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -61,15 +63,17 @@ func NewProxy(
|
||||
encryptionKey []byte,
|
||||
msgTransporter transport.MessageTransporter,
|
||||
vnetController *vnet.Controller,
|
||||
udpPacketCodec string,
|
||||
) (pxy Proxy) {
|
||||
var limiter *rate.Limiter
|
||||
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
|
||||
if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {
|
||||
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
|
||||
limiter = limit.NewBandwidthLimiter(limitBytes)
|
||||
}
|
||||
|
||||
baseProxy := BaseProxy{
|
||||
baseCfg: pxyConf.GetBaseConfig(),
|
||||
configurer: pxyConf,
|
||||
clientCfg: clientCfg,
|
||||
encryptionKey: encryptionKey,
|
||||
limiter: limiter,
|
||||
@@ -77,6 +81,7 @@ func NewProxy(
|
||||
vnetController: vnetController,
|
||||
xl: xlog.FromContextSafe(ctx),
|
||||
ctx: ctx,
|
||||
udpPacketCodec: udpPacketCodec,
|
||||
}
|
||||
|
||||
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
|
||||
@@ -88,6 +93,7 @@ func NewProxy(
|
||||
|
||||
type BaseProxy struct {
|
||||
baseCfg *v1.ProxyBaseConfig
|
||||
configurer v1.ProxyConfigurer
|
||||
clientCfg *v1.ClientCommonConfig
|
||||
encryptionKey []byte
|
||||
msgTransporter transport.MessageTransporter
|
||||
@@ -98,15 +104,17 @@ type BaseProxy struct {
|
||||
proxyPlugin plugin.Plugin
|
||||
inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool
|
||||
|
||||
mu sync.RWMutex
|
||||
xl *xlog.Logger
|
||||
ctx context.Context
|
||||
mu sync.RWMutex
|
||||
xl *xlog.Logger
|
||||
ctx context.Context
|
||||
udpPacketCodec string
|
||||
}
|
||||
|
||||
func (pxy *BaseProxy) Run() error {
|
||||
if pxy.baseCfg.Plugin.Type != "" {
|
||||
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
||||
Name: pxy.baseCfg.Name,
|
||||
HostAllowList: pxy.getPluginHostAllowList(),
|
||||
VnetController: pxy.vnetController,
|
||||
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
||||
if err != nil {
|
||||
@@ -117,6 +125,39 @@ func (pxy *BaseProxy) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pxy *BaseProxy) getPluginHostAllowList() []string {
|
||||
dedupHosts := make([]string, 0)
|
||||
addHost := func(host string) {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
// autocert.HostWhitelist only supports exact host names.
|
||||
if strings.Contains(host, "*") {
|
||||
return
|
||||
}
|
||||
if !slices.Contains(dedupHosts, host) {
|
||||
dedupHosts = append(dedupHosts, host)
|
||||
}
|
||||
}
|
||||
|
||||
switch cfg := pxy.configurer.(type) {
|
||||
case *v1.HTTPProxyConfig:
|
||||
for _, host := range cfg.CustomDomains {
|
||||
addHost(host)
|
||||
}
|
||||
case *v1.HTTPSProxyConfig:
|
||||
for _, host := range cfg.CustomDomains {
|
||||
addHost(host)
|
||||
}
|
||||
case *v1.TCPMuxProxyConfig:
|
||||
for _, host := range cfg.CustomDomains {
|
||||
addHost(host)
|
||||
}
|
||||
}
|
||||
return dedupHosts
|
||||
}
|
||||
|
||||
func (pxy *BaseProxy) Close() {
|
||||
if pxy.proxyPlugin != nil {
|
||||
pxy.proxyPlugin.Close()
|
||||
@@ -171,6 +212,26 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
||||
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
|
||||
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
|
||||
|
||||
var srcAddr, dstAddr *net.TCPAddr
|
||||
if m.SrcAddr != "" && m.SrcPort != 0 {
|
||||
if m.DstAddr == "" {
|
||||
m.DstAddr = "127.0.0.1"
|
||||
}
|
||||
var err error
|
||||
srcAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
|
||||
if err != nil {
|
||||
xl.Warnf("resolve source address [%s] error: %v", m.SrcAddr, err)
|
||||
_ = workConn.Close()
|
||||
return
|
||||
}
|
||||
dstAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
|
||||
if err != nil {
|
||||
xl.Warnf("resolve destination address [%s] error: %v", m.DstAddr, err)
|
||||
_ = workConn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
|
||||
if err != nil {
|
||||
xl.Errorf("wrap work connection: %v", err)
|
||||
@@ -180,11 +241,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
||||
// check if we need to send proxy protocol info
|
||||
var connInfo plugin.ConnectionInfo
|
||||
if m.SrcAddr != "" && m.SrcPort != 0 {
|
||||
if m.DstAddr == "" {
|
||||
m.DstAddr = "127.0.0.1"
|
||||
}
|
||||
srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
|
||||
dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
|
||||
connInfo.SrcAddr = srcAddr
|
||||
connInfo.DstAddr = dstAddr
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ type Manager struct {
|
||||
encryptionKey []byte
|
||||
clientCfg *v1.ClientCommonConfig
|
||||
|
||||
ctx context.Context
|
||||
ctx context.Context
|
||||
udpPacketCodec string
|
||||
}
|
||||
|
||||
func NewManager(
|
||||
@@ -52,6 +53,7 @@ func NewManager(
|
||||
encryptionKey []byte,
|
||||
msgTransporter transport.MessageTransporter,
|
||||
vnetController *vnet.Controller,
|
||||
udpPacketCodec string,
|
||||
) *Manager {
|
||||
return &Manager{
|
||||
proxies: make(map[string]*Wrapper),
|
||||
@@ -61,6 +63,7 @@ func NewManager(
|
||||
encryptionKey: encryptionKey,
|
||||
clientCfg: clientCfg,
|
||||
ctx: ctx,
|
||||
udpPacketCodec: udpPacketCodec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +162,14 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||
}
|
||||
}
|
||||
if len(delPxyNames) > 0 {
|
||||
xl.Infof("proxy removed: %s", delPxyNames)
|
||||
xl.Infof("隧道移除: %s", delPxyNames)
|
||||
}
|
||||
|
||||
addPxyNames := make([]string, 0)
|
||||
for _, cfg := range proxyCfgs {
|
||||
name := cfg.GetBaseConfig().Name
|
||||
if _, ok := pm.proxies[name]; !ok {
|
||||
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
|
||||
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController, pm.udpPacketCodec)
|
||||
if pm.inWorkConnCallback != nil {
|
||||
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
|
||||
}
|
||||
@@ -177,6 +180,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||
}
|
||||
}
|
||||
if len(addPxyNames) > 0 {
|
||||
xl.Infof("proxy added: %s", addPxyNames)
|
||||
xl.Infof("添加隧道: %s", addPxyNames)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !frps
|
||||
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
|
||||
func TestHandleTCPWorkConnectionRejectsInvalidAddress(t *testing.T) {
|
||||
workConn, peerConn := net.Pipe()
|
||||
defer peerConn.Close()
|
||||
|
||||
pxy := &BaseProxy{
|
||||
baseCfg: &v1.ProxyBaseConfig{},
|
||||
xl: xlog.New(),
|
||||
}
|
||||
pxy.HandleTCPWorkConnection(workConn, &msg.StartWorkConn{
|
||||
SrcAddr: "[",
|
||||
SrcPort: 1,
|
||||
}, nil)
|
||||
|
||||
buffer := make([]byte, 1)
|
||||
_, err := peerConn.Read(buffer)
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
@@ -99,6 +99,7 @@ func NewWrapper(
|
||||
eventHandler event.Handler,
|
||||
msgTransporter transport.MessageTransporter,
|
||||
vnetController *vnet.Controller,
|
||||
udpPacketCodec string,
|
||||
) *Wrapper {
|
||||
baseInfo := cfg.GetBaseConfig()
|
||||
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
|
||||
@@ -127,7 +128,7 @@ func NewWrapper(
|
||||
xl.Tracef("enable health check monitor")
|
||||
}
|
||||
|
||||
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
|
||||
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec)
|
||||
return pw
|
||||
}
|
||||
|
||||
|
||||
+14
-7
@@ -87,6 +87,13 @@ 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
|
||||
@@ -109,7 +116,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
|
||||
defer closeFn()
|
||||
|
||||
for {
|
||||
@@ -122,7 +129,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -137,7 +144,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
closeFn()
|
||||
xl.Infof("writer goroutine for sudp work connection closed")
|
||||
@@ -148,12 +155,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(), conn.LocalAddr().String(), conn.RemoteAddr().String())
|
||||
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
|
||||
case *msg.Ping:
|
||||
xl.Tracef("frpc send ping message to frpc visitor")
|
||||
}
|
||||
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
|
||||
xl.Errorf("sudp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -184,8 +191,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(workConn, sendCh)
|
||||
go workConnReaderFn(workConn, readCh)
|
||||
go workConnSenderFn(payloadConn, sendCh)
|
||||
go workConnReaderFn(payloadConn, readCh)
|
||||
go heartbeatFn(sendCh)
|
||||
|
||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||
|
||||
+17
-8
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
|
||||
|
||||
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
|
||||
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
|
||||
// close resources related with old workConn
|
||||
pxy.Close()
|
||||
|
||||
@@ -97,17 +97,26 @@ 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 = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||
pxy.workConn = workConn
|
||||
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
||||
pxy.sendCh = make(chan msg.Message, 1024)
|
||||
pxy.closed = false
|
||||
pxy.mu.Unlock()
|
||||
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||
for {
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +129,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
xl.Infof("writer goroutine for udp work connection closed")
|
||||
}()
|
||||
@@ -132,7 +141,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
case *msg.Ping:
|
||||
xl.Tracef("send ping message to udp workConn")
|
||||
}
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
|
||||
xl.Errorf("udp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -151,8 +160,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
||||
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||
go heartbeatFn(pxy.sendCh)
|
||||
|
||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||
|
||||
+10
-2
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
defer conn.Close()
|
||||
var natHoleSidMsg msg.NatHoleSid
|
||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
||||
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||
if err != nil {
|
||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||
return
|
||||
@@ -131,6 +130,15 @@ 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()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -22,6 +22,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/fatedier/frp/pkg/config"
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
@@ -109,6 +111,9 @@ 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.
|
||||
@@ -149,8 +154,7 @@ type Service struct {
|
||||
// service context
|
||||
ctx context.Context
|
||||
// call cancel to stop service
|
||||
cancel context.CancelCauseFunc
|
||||
gracefulShutdownDuration time.Duration
|
||||
cancel context.CancelCauseFunc
|
||||
|
||||
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
||||
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
|
||||
@@ -260,7 +264,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
cancelCause := cancelErr{}
|
||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||
svr.stop()
|
||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
|
||||
}
|
||||
|
||||
go svr.keepControllerWorking()
|
||||
@@ -305,7 +309,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
xl := xlog.FromContextSafe(svr.ctx)
|
||||
|
||||
loginFunc := func() (bool, error) {
|
||||
xl.Infof("try to connect to server...")
|
||||
xl.Infof("尝试连接到服务器...")
|
||||
dialer := &controlSessionDialer{
|
||||
ctx: svr.ctx,
|
||||
common: svr.common,
|
||||
@@ -316,7 +320,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
}
|
||||
sessionCtx, err := dialer.Dial(svr.runID)
|
||||
if err != nil {
|
||||
xl.Warnf("connect to server error: %v", err)
|
||||
xl.Warnf("连接服务器错误: %v", err)
|
||||
if firstLoginExit {
|
||||
svr.cancel(cancelErr{Err: err})
|
||||
}
|
||||
@@ -412,7 +416,7 @@ func (svr *Service) Close() {
|
||||
}
|
||||
|
||||
func (svr *Service) GracefulClose(d time.Duration) {
|
||||
svr.gracefulShutdownDuration = d
|
||||
svr.gracefulShutdownDuration.Store(int64(d))
|
||||
svr.cancel(nil)
|
||||
}
|
||||
|
||||
@@ -429,7 +433,8 @@ func (svr *Service) stop() {
|
||||
svr.ctlMu.Lock()
|
||||
defer svr.ctlMu.Unlock()
|
||||
if svr.ctl != nil {
|
||||
svr.ctl.GracefulClose(svr.gracefulShutdownDuration)
|
||||
d := time.Duration(svr.gracefulShutdownDuration.Load())
|
||||
svr.ctl.GracefulClose(d)
|
||||
svr.ctl = nil
|
||||
}
|
||||
if svr.webServer != nil {
|
||||
@@ -506,6 +511,13 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
|
||||
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
|
||||
proxies = config.CompleteProxyConfigurers(proxies)
|
||||
visitors = config.CompleteVisitorConfigurers(visitors)
|
||||
requirements := validation.GetClientConfigRequirements(reloadCommon, proxies, visitors)
|
||||
if svr.vnetController == nil && requirements.VirtualNet {
|
||||
return errors.New(
|
||||
"VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup; " +
|
||||
"restart frpc after configuring featureGates.VirtualNet and virtualNet.address",
|
||||
)
|
||||
}
|
||||
|
||||
// Atomically replace the entire configuration
|
||||
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -113,15 +113,22 @@ 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(conn net.Conn) {
|
||||
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
payloadConn.Close()
|
||||
close(closeCh)
|
||||
wg.Done()
|
||||
}()
|
||||
@@ -133,13 +140,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
)
|
||||
|
||||
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
||||
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||
return
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||
switch m := rawMsg.(type) {
|
||||
case *msg.Ping:
|
||||
xl.Debugf("frpc visitor get ping message from frpc")
|
||||
@@ -157,15 +164,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnSenderFn := func(conn net.Conn) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
payloadConn.Close()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
var errRet error
|
||||
if firstPacket != nil {
|
||||
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -180,7 +187,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
return
|
||||
}
|
||||
|
||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -191,8 +198,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnReaderFn(workConn)
|
||||
go workConnSenderFn(workConn)
|
||||
go workConnReaderFn(payloadConn)
|
||||
go workConnSenderFn(payloadConn)
|
||||
|
||||
wg.Wait()
|
||||
xl.Infof("sudp worker is closed")
|
||||
|
||||
@@ -50,6 +50,17 @@ type Helper interface {
|
||||
RunID() string
|
||||
}
|
||||
|
||||
type udpPacketCodecProvider interface {
|
||||
UDPPacketCodec() string
|
||||
}
|
||||
|
||||
func udpPacketCodecFromHelper(helper Helper) string {
|
||||
if provider, ok := helper.(udpPacketCodecProvider); ok {
|
||||
return provider.UDPPacketCodec()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Visitor is used for forward traffics from local port tot remote service.
|
||||
type Visitor interface {
|
||||
Run() error
|
||||
|
||||
@@ -53,7 +53,12 @@ func NewManager(
|
||||
connectServer func() (*msg.Conn, error),
|
||||
msgTransporter transport.MessageTransporter,
|
||||
vnetController *vnet.Controller,
|
||||
udpPacketCodecs ...string,
|
||||
) *Manager {
|
||||
udpPacketCodec := ""
|
||||
if len(udpPacketCodecs) > 0 {
|
||||
udpPacketCodec = udpPacketCodecs[0]
|
||||
}
|
||||
m := &Manager{
|
||||
clientCfg: clientCfg,
|
||||
cfgs: make(map[string]v1.VisitorConfigurer),
|
||||
@@ -68,6 +73,7 @@ func NewManager(
|
||||
vnetController: vnetController,
|
||||
transferConnFn: m.TransferConn,
|
||||
runID: runID,
|
||||
udpPacketCodec: udpPacketCodec,
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -205,6 +211,7 @@ type visitorHelperImpl struct {
|
||||
vnetController *vnet.Controller
|
||||
transferConnFn func(name string, conn net.Conn) error
|
||||
runID string
|
||||
udpPacketCodec string
|
||||
}
|
||||
|
||||
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
|
||||
@@ -226,3 +233,7 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller {
|
||||
func (v *visitorHelperImpl) RunID() string {
|
||||
return v.runID
|
||||
}
|
||||
|
||||
func (v *visitorHelperImpl) UDPPacketCodec() string {
|
||||
return v.udpPacketCodec
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
|
||||
Use: name,
|
||||
Short: short,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||
fmt.Println("frpc: the configuration file is not specified")
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
|
||||
+13
-2
@@ -48,8 +48,19 @@ var natholeDiscoveryCmd = &cobra.Command{
|
||||
Short: "Discover nathole information from stun server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// ignore error here, because we can use command line parameters
|
||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||
if err != nil {
|
||||
var cfg *v1.ClientCommonConfig
|
||||
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
|
||||
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||
if err != nil {
|
||||
cfg = &v1.ClientCommonConfig{}
|
||||
if err := cfg.Complete(); err != nil {
|
||||
fmt.Printf("failed to complete config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
cfg = loaded
|
||||
}
|
||||
} else {
|
||||
cfg = &v1.ClientCommonConfig{}
|
||||
if err := cfg.Complete(); err != nil {
|
||||
fmt.Printf("failed to complete config: %v\n", err)
|
||||
|
||||
@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
|
||||
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
|
||||
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
@@ -141,11 +141,13 @@ func startService(
|
||||
visitorCfgs []v1.VisitorConfigurer,
|
||||
unsafeFeatures *security.UnsafeFeatures,
|
||||
cfgFile string,
|
||||
nodeName string,
|
||||
tunnelRemark string,
|
||||
) error {
|
||||
configSource := source.NewConfigSource()
|
||||
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
||||
return fmt.Errorf("failed to set config source: %w", err)
|
||||
}
|
||||
aggregator := source.NewAggregator(configSource)
|
||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
|
||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
|
||||
}
|
||||
|
||||
+246
-15
@@ -16,8 +16,11 @@ package sub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -33,26 +36,29 @@ import (
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/policy/featuregate"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
"github.com/fatedier/frp/pkg/util/banner"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
"github.com/fatedier/frp/pkg/util/version"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
cfgFiles []string
|
||||
cfgDir string
|
||||
showVersion bool
|
||||
strictConfigMode bool
|
||||
allowUnsafe []string
|
||||
authTokens []string
|
||||
|
||||
bannerDisplayed bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
|
||||
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
||||
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
|
||||
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
|
||||
|
||||
rootCmd.PersistentFlags().StringSliceVarP(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
|
||||
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
||||
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
||||
}
|
||||
@@ -68,15 +74,30 @@ var rootCmd = &cobra.Command{
|
||||
|
||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
||||
|
||||
// If authTokens is provided, fetch config from API
|
||||
if len(authTokens) > 0 {
|
||||
err := runClientWithTokens(authTokens, unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
|
||||
// Note that it's only designed for testing. It's not guaranteed to be stable.
|
||||
if cfgDir != "" {
|
||||
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If multiple config files are specified, run one frpc service for each file
|
||||
if len(cfgFiles) > 1 {
|
||||
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not show command usage here.
|
||||
err := runClient(cfgFile, unsafeFeatures)
|
||||
err := runClient(cfgFiles[0], unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
@@ -106,6 +127,29 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
|
||||
return err
|
||||
}
|
||||
|
||||
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Display banner first
|
||||
banner.DisplayBanner()
|
||||
bannerDisplayed = true
|
||||
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
|
||||
|
||||
for _, cfgFile := range cfgFiles {
|
||||
wg.Add(1)
|
||||
// Add a small delay to avoid log output mixing
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
go func(path string) {
|
||||
defer wg.Done()
|
||||
err := runClient(path, unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
|
||||
}
|
||||
}(cfgFile)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
@@ -131,12 +175,6 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
|
||||
"please use yaml/json/toml format instead!\n")
|
||||
}
|
||||
|
||||
if len(result.Common.FeatureGates) > 0 {
|
||||
if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
|
||||
}
|
||||
|
||||
@@ -186,7 +224,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
|
||||
return err
|
||||
}
|
||||
|
||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
|
||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "")
|
||||
}
|
||||
|
||||
func startServiceWithAggregator(
|
||||
@@ -194,12 +232,25 @@ func startServiceWithAggregator(
|
||||
aggregator *source.Aggregator,
|
||||
unsafeFeatures *security.UnsafeFeatures,
|
||||
cfgFile string,
|
||||
nodeName string,
|
||||
tunnelRemark string,
|
||||
) error {
|
||||
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
||||
|
||||
// Display banner only once before starting the first service
|
||||
if !bannerDisplayed {
|
||||
banner.DisplayBanner()
|
||||
bannerDisplayed = true
|
||||
}
|
||||
|
||||
// Display node information if available
|
||||
if nodeName != "" {
|
||||
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
|
||||
}
|
||||
|
||||
if cfgFile != "" {
|
||||
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
|
||||
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
|
||||
log.Infof("启动 frpc 服务 [%s]", cfgFile)
|
||||
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
|
||||
}
|
||||
svr, err := client.NewService(client.ServiceOptions{
|
||||
Common: cfg,
|
||||
@@ -217,3 +268,183 @@ func startServiceWithAggregator(
|
||||
}
|
||||
return svr.Run(context.Background())
|
||||
}
|
||||
|
||||
// APIResponse represents the response from LoliaFRP API
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Config string `json:"config"`
|
||||
NodeName string `json:"node_name"`
|
||||
TunnelRemark string `json:"tunnel_remark"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TokenInfo stores parsed id and token from the -t parameter
|
||||
type TokenInfo struct {
|
||||
ID string
|
||||
Token string
|
||||
}
|
||||
|
||||
func runClientWithTokens(tokens []string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||
// Parse all tokens (format: id:token)
|
||||
tokenInfos := make([]TokenInfo, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
parts := strings.SplitN(t, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid token format '%s', expected 'id:token'", t)
|
||||
}
|
||||
tokenInfos = append(tokenInfos, TokenInfo{
|
||||
ID: strings.TrimSpace(parts[0]),
|
||||
Token: strings.TrimSpace(parts[1]),
|
||||
})
|
||||
}
|
||||
|
||||
// Group tokens by token value (same token can have multiple IDs)
|
||||
tokenToIDs := make(map[string][]string)
|
||||
for _, ti := range tokenInfos {
|
||||
tokenToIDs[ti.Token] = append(tokenToIDs[ti.Token], ti.ID)
|
||||
}
|
||||
|
||||
// If we have multiple different tokens, start one service for each token group
|
||||
if len(tokenToIDs) > 1 {
|
||||
return runMultipleClientsWithTokens(tokenToIDs, unsafeFeatures)
|
||||
}
|
||||
|
||||
// Get the single token and all its IDs
|
||||
var token string
|
||||
var ids []string
|
||||
for t, idList := range tokenToIDs {
|
||||
token = t
|
||||
ids = idList
|
||||
break
|
||||
}
|
||||
|
||||
return runClientWithTokenAndIDs(token, ids, unsafeFeatures)
|
||||
}
|
||||
|
||||
func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||
// Get API server address from environment variable
|
||||
apiServer := os.Getenv("LOLIA_API")
|
||||
if apiServer == "" {
|
||||
apiServer = "https://api.lolia.link"
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ","))
|
||||
// URL is constructed from trusted source (environment variable or hardcoded)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API request: %v", err)
|
||||
}
|
||||
// Carry client version in User-Agent so the API knows which frpc version is requesting
|
||||
req.Header.Set("User-Agent", version.Full())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch config from API: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return fmt.Errorf("failed to decode API response: %v", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return fmt.Errorf("API error: %s", apiResp.Msg)
|
||||
}
|
||||
|
||||
// Decode base64 config
|
||||
configBytes, err := base64.StdEncoding.DecodeString(apiResp.Data.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode base64 config: %v", err)
|
||||
}
|
||||
|
||||
// Load config directly from bytes
|
||||
return runClientWithConfig(configBytes, unsafeFeatures, apiResp.Data.NodeName, apiResp.Data.TunnelRemark)
|
||||
}
|
||||
|
||||
func runMultipleClientsWithTokens(tokenToIDs map[string][]string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Display banner first
|
||||
banner.DisplayBanner()
|
||||
bannerDisplayed = true
|
||||
log.Infof("检测到 %d 个不同的 token,将并行启动多个 frpc 服务实例", len(tokenToIDs))
|
||||
|
||||
index := 0
|
||||
for token, ids := range tokenToIDs {
|
||||
wg.Add(1)
|
||||
currentIndex := index
|
||||
currentToken := token
|
||||
currentIDs := ids
|
||||
totalCount := len(tokenToIDs)
|
||||
|
||||
// Add a small delay to avoid log output mixing
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
maskedToken := currentToken
|
||||
if len(maskedToken) > 6 {
|
||||
maskedToken = maskedToken[:3] + "***" + maskedToken[len(maskedToken)-3:]
|
||||
} else {
|
||||
maskedToken = "***"
|
||||
}
|
||||
log.Infof("[%d/%d] 启动 token: %s (IDs: %v)", currentIndex+1, totalCount, maskedToken, currentIDs)
|
||||
err := runClientWithTokenAndIDs(currentToken, currentIDs, unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Printf("\nToken [%s] 启动失败: %v\n", maskedToken, err)
|
||||
}
|
||||
}()
|
||||
index++
|
||||
}
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeatures, nodeName, tunnelRemark string) error {
|
||||
// Render template first
|
||||
renderedBytes, err := config.RenderWithTemplate(configBytes, config.GetValues())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render template: %v", err)
|
||||
}
|
||||
|
||||
var allCfg v1.ClientConfig
|
||||
if err := config.LoadConfigure(renderedBytes, &allCfg, strictConfigMode); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &allCfg.ClientCommonConfig
|
||||
proxyCfgs := make([]v1.ProxyConfigurer, 0, len(allCfg.Proxies))
|
||||
for _, c := range allCfg.Proxies {
|
||||
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
|
||||
}
|
||||
visitorCfgs := make([]v1.VisitorConfigurer, 0, len(allCfg.Visitors))
|
||||
for _, c := range allCfg.Visitors {
|
||||
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
|
||||
}
|
||||
|
||||
// Call Complete to fill in default values
|
||||
if err := cfg.Complete(); err != nil {
|
||||
return fmt.Errorf("failed to complete config: %v", err)
|
||||
}
|
||||
|
||||
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
|
||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||
|
||||
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
||||
if warning != nil {
|
||||
fmt.Printf("WARNING: %v\n", warning)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, "", nodeName, tunnelRemark)
|
||||
}
|
||||
|
||||
+15
-7
@@ -29,22 +29,30 @@ func init() {
|
||||
rootCmd.AddCommand(verifyCmd)
|
||||
}
|
||||
|
||||
func verifyClientConfig(
|
||||
configFile string,
|
||||
strict bool,
|
||||
unsafeFeatures *security.UnsafeFeatures,
|
||||
) (validation.Warning, error) {
|
||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(configFile, strict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
||||
}
|
||||
|
||||
var verifyCmd = &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "Verify that the configures is valid",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if cfgFile == "" {
|
||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||
fmt.Println("frpc: the configuration file is not specified")
|
||||
return nil
|
||||
}
|
||||
|
||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cfgFile := cfgFiles[0]
|
||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
||||
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
||||
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
|
||||
if warning != nil {
|
||||
fmt.Printf("WARNING: %v\n", warning)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package sub
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
)
|
||||
|
||||
func TestVerifyClientConfigFeatureGates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "VirtualNet enabled",
|
||||
content: `featureGates = { VirtualNet = true }
|
||||
virtualNet.address = "100.86.0.4/24"
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "VirtualNet disabled",
|
||||
content: `featureGates = { VirtualNet = false }
|
||||
virtualNet.address = "100.86.0.4/24"
|
||||
`,
|
||||
wantErr: "VirtualNet feature is not enabled",
|
||||
},
|
||||
{
|
||||
name: "unknown feature gate",
|
||||
content: `featureGates = { UnknownFeature = true }`,
|
||||
wantErr: "unrecognized feature gate: UnknownFeature",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
configFile := filepath.Join(t.TempDir(), "frpc.toml")
|
||||
require.NoError(t, os.WriteFile(configFile, []byte(tc.content), 0o600))
|
||||
|
||||
warning, err := verifyClientConfig(configFile, true, security.NewUnsafeFeatures(nil))
|
||||
require.NoError(t, warning)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,10 @@ localIP = "127.0.0.1"
|
||||
localPort = 8000
|
||||
subdomain = "web02"
|
||||
customDomains = ["web02.yourdomain.com"]
|
||||
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
|
||||
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
|
||||
# on the same domain always takes precedence over the redirect.
|
||||
# httpRedirect = true
|
||||
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
|
||||
# v1 or v2 or empty
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
@@ -336,6 +340,14 @@ type = "https2http"
|
||||
localAddr = "127.0.0.1:80"
|
||||
crtPath = "./server.crt"
|
||||
keyPath = "./server.key"
|
||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||
# [proxies.plugin.autoTLS]
|
||||
# enable = true
|
||||
# email = "admin@example.com"
|
||||
# cacheDir = "./.autotls-cache"
|
||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||
# hostAllowList = ["test.yourdomain.com"]
|
||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
hostHeaderRewrite = "127.0.0.1"
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
|
||||
@@ -348,6 +360,14 @@ type = "https2https"
|
||||
localAddr = "127.0.0.1:443"
|
||||
crtPath = "./server.crt"
|
||||
keyPath = "./server.key"
|
||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||
# [proxies.plugin.autoTLS]
|
||||
# enable = true
|
||||
# email = "admin@example.com"
|
||||
# cacheDir = "./.autotls-cache"
|
||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||
# hostAllowList = ["test.yourdomain.com"]
|
||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
hostHeaderRewrite = "127.0.0.1"
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
|
||||
@@ -361,6 +381,15 @@ localAddr = "127.0.0.1:443"
|
||||
hostHeaderRewrite = "127.0.0.1"
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
|
||||
[[proxies]]
|
||||
name = "plugin_http2https_redirect"
|
||||
type = "http"
|
||||
customDomains = ["test.yourdomain.com"]
|
||||
[proxies.plugin]
|
||||
type = "http2https_redirect"
|
||||
# Optional. Defaults to 443. Set this if the HTTPS entry is exposed on a non-standard port.
|
||||
# httpsPort = 443
|
||||
|
||||
[[proxies]]
|
||||
name = "plugin_http2http"
|
||||
type = "tcp"
|
||||
@@ -380,6 +409,14 @@ type = "tls2raw"
|
||||
localAddr = "127.0.0.1:80"
|
||||
crtPath = "./server.crt"
|
||||
keyPath = "./server.key"
|
||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||
# [proxies.plugin.autoTLS]
|
||||
# enable = true
|
||||
# email = "admin@example.com"
|
||||
# cacheDir = "./.autotls-cache"
|
||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||
# hostAllowList = ["test.yourdomain.com"]
|
||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
[[proxies]]
|
||||
name = "secret_tcp"
|
||||
|
||||
@@ -40,6 +40,12 @@ transport.maxPoolCount = 5
|
||||
# If negative, keep-alive probes are disabled.
|
||||
# transport.tcpKeepalive = 7200
|
||||
|
||||
# proxyIdleTimeout specifies the maximum time in seconds that a proxied user connection
|
||||
# can stay open without any traffic in either direction before frps closes it.
|
||||
# It reclaims connections whose endpoints are still open at the TCP level but will never send data again.
|
||||
# By default, this value is 0, which disables the idle timeout.
|
||||
# transport.proxyIdleTimeout = 7200
|
||||
|
||||
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
|
||||
transport.tls.force = false
|
||||
|
||||
@@ -52,6 +58,12 @@ transport.tls.force = false
|
||||
vhostHTTPPort = 80
|
||||
vhostHTTPSPort = 443
|
||||
|
||||
# Port used in the Location header when redirecting HTTP requests to HTTPS for
|
||||
# proxies with httpRedirect enabled. Set it when browsers reach the HTTPS vhost
|
||||
# through a port mapping, so it differs from vhostHTTPSPort.
|
||||
# By default, this value is vhostHTTPSPort.
|
||||
# vhostHTTPSRedirectPort = 443
|
||||
|
||||
# Response header timeout(seconds) for vhost http server, default is 60s
|
||||
# vhostHTTPTimeout = 60
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/fatedier/golib v0.7.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/fatedier/golib v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
@@ -13,10 +15,9 @@ 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/pion/stun/v3 v3.1.1
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/pires/go-proxyproto v0.15.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.55.0
|
||||
github.com/quic-go/quic-go v0.60.0
|
||||
github.com/rodaine/table v1.2.0
|
||||
github.com/samber/lo v1.47.0
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
@@ -26,11 +27,11 @@ 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.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/sys v0.42.0
|
||||
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/time v0.10.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
@@ -41,10 +42,16 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
@@ -53,25 +60,27 @@ 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/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/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/templexxx/cpu v0.1.1 // indirect
|
||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
@@ -82,3 +91,6 @@ require (
|
||||
|
||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
||||
|
||||
// Use the Lolia-FRP fork of golib: io.Join relays with adaptively sized buffers.
|
||||
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.0.0-20260810035216-6e2252e33564
|
||||
|
||||
@@ -2,17 +2,33 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Lolia-FRP/golib v0.0.0-20260810035216-6e2252e33564 h1:L9XoKV/oAoTJNe/h93oNPlhMf6EsM0V54/tpucjbz1A=
|
||||
github.com/Lolia-FRP/golib v0.0.0-20260810035216-6e2252e33564/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
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/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=
|
||||
@@ -20,12 +36,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||
github.com/fatedier/golib v0.7.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.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
@@ -70,24 +86,23 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/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/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -103,10 +118,13 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/quic-go v0.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/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/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=
|
||||
@@ -146,8 +164,8 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||
@@ -159,45 +177,46 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
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/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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/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.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/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/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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
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/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=
|
||||
@@ -205,8 +224,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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ rm -rf ./release/packages
|
||||
mkdir -p ./release/packages
|
||||
|
||||
os_all='linux windows darwin freebsd openbsd android'
|
||||
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
|
||||
arch_all='amd64 arm arm64'
|
||||
extra_all='_ hf'
|
||||
|
||||
cd ./release
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
|
||||
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
|
||||
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
|
||||
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
|
||||
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
|
||||
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
|
||||
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
||||
}
|
||||
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
||||
|
||||
@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -417,6 +421,31 @@ 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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -462,6 +464,111 @@ 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)
|
||||
|
||||
@@ -49,7 +49,8 @@ type ClientCommonConfig struct {
|
||||
// STUN server to help penetrate NAT hole.
|
||||
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
||||
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
||||
// is "", the default DNS will be used.
|
||||
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
|
||||
// supported, e.g. "https://1.1.1.1/dns-query".
|
||||
DNSServer string `json:"dnsServer,omitempty"`
|
||||
// LoginFailExit controls whether or not the client should exit after a
|
||||
// failed login attempt. If false, the client will retry until a login
|
||||
|
||||
@@ -369,6 +369,12 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
|
||||
type HTTPSProxyConfig struct {
|
||||
ProxyBaseConfig
|
||||
DomainConfig
|
||||
|
||||
// HTTPRedirect requests frps to redirect plain HTTP requests for the
|
||||
// proxy's domains to their HTTPS endpoint. It only takes effect when
|
||||
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
|
||||
// proxy registered on the same domain.
|
||||
HTTPRedirect bool `json:"httpRedirect,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
@@ -376,6 +382,7 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
|
||||
m.CustomDomains = c.CustomDomains
|
||||
m.SubDomain = c.SubDomain
|
||||
m.HTTPRedirect = c.HTTPRedirect
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||
@@ -383,6 +390,7 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||
|
||||
c.CustomDomains = m.CustomDomains
|
||||
c.SubDomain = m.SubDomain
|
||||
c.HTTPRedirect = m.HTTPRedirect
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
||||
|
||||
@@ -16,6 +16,7 @@ package v1
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
@@ -24,29 +25,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PluginHTTP2HTTPS = "http2https"
|
||||
PluginHTTPProxy = "http_proxy"
|
||||
PluginHTTPS2HTTP = "https2http"
|
||||
PluginHTTPS2HTTPS = "https2https"
|
||||
PluginHTTP2HTTP = "http2http"
|
||||
PluginSocks5 = "socks5"
|
||||
PluginStaticFile = "static_file"
|
||||
PluginUnixDomainSocket = "unix_domain_socket"
|
||||
PluginTLS2Raw = "tls2raw"
|
||||
PluginVirtualNet = "virtual_net"
|
||||
PluginHTTP2HTTPS = "http2https"
|
||||
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
||||
PluginHTTPProxy = "http_proxy"
|
||||
PluginHTTPS2HTTP = "https2http"
|
||||
PluginHTTPS2HTTPS = "https2https"
|
||||
PluginHTTP2HTTP = "http2http"
|
||||
PluginSocks5 = "socks5"
|
||||
PluginStaticFile = "static_file"
|
||||
PluginUnixDomainSocket = "unix_domain_socket"
|
||||
PluginTLS2Raw = "tls2raw"
|
||||
PluginVirtualNet = "virtual_net"
|
||||
)
|
||||
|
||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
|
||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||
}
|
||||
|
||||
type ClientPluginOptions interface {
|
||||
@@ -80,6 +83,29 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
|
||||
return jsonx.Marshal(c.ClientPluginOptions)
|
||||
}
|
||||
|
||||
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
|
||||
// that terminate TLS locally.
|
||||
type AutoTLSOptions struct {
|
||||
Enable bool `json:"enable,omitempty"`
|
||||
// Contact email for certificate expiration and important notices.
|
||||
Email string `json:"email,omitempty"`
|
||||
// Directory used to cache ACME account and certificates.
|
||||
CacheDir string `json:"cacheDir,omitempty"`
|
||||
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
|
||||
CADirURL string `json:"caDirURL,omitempty"`
|
||||
// Restrict certificate issuance to the listed domains.
|
||||
HostAllowList []string `json:"hostAllowList,omitempty"`
|
||||
}
|
||||
|
||||
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
out.HostAllowList = slices.Clone(o.HostAllowList)
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTP2HTTPSPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
@@ -98,6 +124,21 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTP2HTTPSRedirectPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
HTTPSPort int `json:"httpsPort,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
|
||||
|
||||
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTPProxyPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
HTTPUser string `json:"httpUser,omitempty"`
|
||||
@@ -122,6 +163,7 @@ type HTTPS2HTTPPluginOptions struct {
|
||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||
@@ -135,6 +177,7 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
||||
out := *o
|
||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -146,6 +189,7 @@ type HTTPS2HTTPSPluginOptions struct {
|
||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||
@@ -159,6 +203,7 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
out := *o
|
||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -230,10 +275,11 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
|
||||
}
|
||||
|
||||
type TLS2RawPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *TLS2RawPluginOptions) Complete() {}
|
||||
@@ -243,6 +289,7 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -51,6 +51,12 @@ type ServerConfig struct {
|
||||
// Vhost requests. If this value is 0, the server will not listen for HTTPS
|
||||
// requests.
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
|
||||
// VhostHTTPSRedirectPort specifies the port used in the Location header
|
||||
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
|
||||
// enabled. Set it when browsers reach the HTTPS vhost through a port
|
||||
// mapping, so it differs from VhostHTTPSPort. By default, this value is
|
||||
// VhostHTTPSPort.
|
||||
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
|
||||
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
|
||||
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
|
||||
// requests on one single port. If it's not - it will listen on this value for
|
||||
@@ -118,6 +124,7 @@ func (c *ServerConfig) Complete() error {
|
||||
}
|
||||
|
||||
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
||||
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
|
||||
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
|
||||
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||
@@ -167,12 +174,18 @@ type ServerTransportConfig struct {
|
||||
// If negative, keep-alive probes are disabled.
|
||||
TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"`
|
||||
// MaxPoolCount specifies the maximum pool size for each proxy. By default,
|
||||
// this value is 5.
|
||||
// this value is 5. Negative values are invalid.
|
||||
MaxPoolCount int64 `json:"maxPoolCount,omitempty"`
|
||||
// HeartBeatTimeout specifies the maximum time to wait for a heartbeat
|
||||
// before terminating the connection. It is not recommended to change this
|
||||
// value. By default, this value is 90. Set negative value to disable it.
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
|
||||
// ProxyIdleTimeout specifies the maximum time in seconds that a proxied
|
||||
// user connection can stay open without any traffic in either direction
|
||||
// before frps closes it. This reclaims connections whose endpoints are
|
||||
// still open at the TCP level but will never send data again. By default,
|
||||
// this value is 0, which disables the idle timeout.
|
||||
ProxyIdleTimeout int64 `json:"proxyIdleTimeout,omitempty"`
|
||||
// QUIC options.
|
||||
QUIC QUICOptions `json:"quic,omitempty"`
|
||||
// TLS specifies TLS settings for the connection from the client.
|
||||
|
||||
@@ -51,14 +51,51 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
|
||||
}
|
||||
|
||||
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
|
||||
gates := featuregate.NewFeatureGate()
|
||||
if err := gates.SetFromMap(c.FeatureGates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.VirtualNet.Address != "" {
|
||||
if !featuregate.Enabled(featuregate.VirtualNet) {
|
||||
if !gates.Enabled(featuregate.VirtualNet) {
|
||||
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ClientConfigRequirements describes runtime capabilities needed by a client configuration.
|
||||
type ClientConfigRequirements struct {
|
||||
VirtualNet bool
|
||||
}
|
||||
|
||||
// GetClientConfigRequirements returns the runtime capabilities needed by a client configuration.
|
||||
func GetClientConfigRequirements(
|
||||
common *v1.ClientCommonConfig,
|
||||
proxyCfgs []v1.ProxyConfigurer,
|
||||
visitorCfgs []v1.VisitorConfigurer,
|
||||
) ClientConfigRequirements {
|
||||
requirements := ClientConfigRequirements{}
|
||||
if common != nil && common.VirtualNet.Address != "" {
|
||||
requirements.VirtualNet = true
|
||||
}
|
||||
for _, cfg := range proxyCfgs {
|
||||
if cfg.GetBaseConfig().Plugin.Type == v1.PluginVirtualNet {
|
||||
requirements.VirtualNet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !requirements.VirtualNet {
|
||||
for _, cfg := range visitorCfgs {
|
||||
if cfg.GetBaseConfig().Plugin.Type == v1.VisitorPluginVirtualNet {
|
||||
requirements.VirtualNet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return requirements
|
||||
}
|
||||
|
||||
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
|
||||
var errs error
|
||||
if !slices.Contains(SupportedAuthMethods, c.Method) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/policy/featuregate"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
)
|
||||
|
||||
func validateClientFeatureGates(t *testing.T, gates map[string]bool, virtualNetAddress string) error {
|
||||
t.Helper()
|
||||
|
||||
cfg := &v1.ClientCommonConfig{
|
||||
FeatureGates: gates,
|
||||
VirtualNet: v1.VirtualNetConfig{
|
||||
Address: virtualNetAddress,
|
||||
},
|
||||
}
|
||||
require.NoError(t, cfg.Complete())
|
||||
|
||||
_, err := NewConfigValidator(security.NewUnsafeFeatures(nil)).ValidateClientCommonConfig(cfg)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestValidateClientFeatureGates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
featureGates map[string]bool
|
||||
virtualNetAddress string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "VirtualNet enabled",
|
||||
featureGates: map[string]bool{"VirtualNet": true},
|
||||
virtualNetAddress: "100.86.0.4/24",
|
||||
},
|
||||
{
|
||||
name: "VirtualNet explicitly disabled",
|
||||
featureGates: map[string]bool{"VirtualNet": false},
|
||||
virtualNetAddress: "100.86.0.4/24",
|
||||
wantErr: "VirtualNet feature is not enabled",
|
||||
},
|
||||
{
|
||||
name: "VirtualNet disabled by default",
|
||||
virtualNetAddress: "100.86.0.4/24",
|
||||
wantErr: "VirtualNet feature is not enabled",
|
||||
},
|
||||
{
|
||||
name: "unknown feature gate",
|
||||
featureGates: map[string]bool{"UnknownFeature": true},
|
||||
wantErr: "unrecognized feature gate: UnknownFeature",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateClientFeatureGates(t, tc.featureGates, tc.virtualNetAddress)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientConfigRequirements(t *testing.T) {
|
||||
virtualNetProxy := &v1.STCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
Plugin: v1.TypedClientPluginOptions{Type: v1.PluginVirtualNet},
|
||||
},
|
||||
},
|
||||
}
|
||||
virtualNetVisitor := &v1.STCPVisitorConfig{
|
||||
VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||
Plugin: v1.TypedVisitorPluginOptions{Type: v1.VisitorPluginVirtualNet},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
common *v1.ClientCommonConfig
|
||||
proxies []v1.ProxyConfigurer
|
||||
visitors []v1.VisitorConfigurer
|
||||
wantVNet bool
|
||||
}{
|
||||
{name: "no requirements"},
|
||||
{
|
||||
name: "common VirtualNet address",
|
||||
common: &v1.ClientCommonConfig{VirtualNet: v1.VirtualNetConfig{Address: "100.86.0.4/24"}},
|
||||
wantVNet: true,
|
||||
},
|
||||
{name: "VirtualNet proxy", proxies: []v1.ProxyConfigurer{virtualNetProxy}, wantVNet: true},
|
||||
{name: "VirtualNet visitor", visitors: []v1.VisitorConfigurer{virtualNetVisitor}, wantVNet: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := GetClientConfigRequirements(tc.common, tc.proxies, tc.visitors)
|
||||
require.Equal(t, tc.wantVNet, got.VirtualNet)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClientFeatureGatesAreConfigScoped(t *testing.T) {
|
||||
defaultGatesBefore := featuregate.DefaultFeatureGates.String()
|
||||
|
||||
require.NoError(t, validateClientFeatureGates(
|
||||
t,
|
||||
map[string]bool{"VirtualNet": true},
|
||||
"100.86.0.4/24",
|
||||
))
|
||||
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
|
||||
|
||||
err := validateClientFeatureGates(
|
||||
t,
|
||||
map[string]bool{"VirtualNet": false},
|
||||
"100.86.0.4/24",
|
||||
)
|
||||
require.ErrorContains(t, err, "VirtualNet feature is not enabled")
|
||||
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
|
||||
}
|
||||
@@ -16,6 +16,8 @@ package validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
@@ -24,6 +26,8 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
|
||||
switch v := c.(type) {
|
||||
case *v1.HTTP2HTTPSPluginOptions:
|
||||
return validateHTTP2HTTPSPluginOptions(v)
|
||||
case *v1.HTTP2HTTPSRedirectPluginOptions:
|
||||
return validateHTTP2HTTPSRedirectPluginOptions(v)
|
||||
case *v1.HTTPS2HTTPPluginOptions:
|
||||
return validateHTTPS2HTTPPluginOptions(v)
|
||||
case *v1.HTTPS2HTTPSPluginOptions:
|
||||
@@ -45,10 +49,17 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
|
||||
return ValidatePort(c.HTTPSPort, "httpsPort")
|
||||
}
|
||||
|
||||
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
||||
if c.LocalAddr == "" {
|
||||
return errors.New("localAddr is required")
|
||||
}
|
||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,6 +67,9 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
|
||||
if c.LocalAddr == "" {
|
||||
return errors.New("localAddr is required")
|
||||
}
|
||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,5 +91,29 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
|
||||
if c.LocalAddr == "" {
|
||||
return errors.New("localAddr is required")
|
||||
}
|
||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
|
||||
if c == nil || !c.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if crtPath != "" || keyPath != "" {
|
||||
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
|
||||
}
|
||||
if strings.TrimSpace(c.CacheDir) == "" {
|
||||
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
|
||||
}
|
||||
if len(c.HostAllowList) > 0 {
|
||||
for _, host := range c.HostAllowList {
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -79,9 +79,11 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
|
||||
}
|
||||
|
||||
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
|
||||
subDomainHost := strings.ToLower(s.SubDomainHost)
|
||||
for _, domain := range c.CustomDomains {
|
||||
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
|
||||
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
|
||||
canonicalDomain := strings.ToLower(domain)
|
||||
if subDomainHost != "" && len(strings.Split(subDomainHost, ".")) < len(strings.Split(canonicalDomain, ".")) {
|
||||
if strings.HasSuffix(canonicalDomain, "."+subDomainHost) {
|
||||
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subDomainHost string
|
||||
customDomain string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "lowercase subdomain",
|
||||
subDomainHost: "frp.example.com",
|
||||
customDomain: "victim.frp.example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed case custom domain",
|
||||
subDomainHost: "frp.example.com",
|
||||
customDomain: "victim.FRP.example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed case wildcard domain",
|
||||
subDomainHost: "frp.example.com",
|
||||
customDomain: "*.FRP.example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed case subdomain host",
|
||||
subDomainHost: "FRP.Example.Com",
|
||||
customDomain: "victim.frp.example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "external domain",
|
||||
subDomainHost: "frp.example.com",
|
||||
customDomain: "victim.example.net",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateDomainConfigForServer(
|
||||
&v1.DomainConfig{CustomDomains: []string{tt.customDomain}},
|
||||
&v1.ServerConfig{SubDomainHost: tt.subDomainHost},
|
||||
)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, "should not belong to subdomain host")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
||||
errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort"))
|
||||
errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort"))
|
||||
errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort"))
|
||||
if c.Transport.MaxPoolCount < 0 {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative"))
|
||||
}
|
||||
|
||||
for _, p := range c.HTTPPlugins {
|
||||
if !lo.Every(SupportedHTTPPluginOps, p.Ops) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestValidateServerConfigMaxPoolCount(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
maxPoolCount int64
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "negative", maxPoolCount: -1, wantErr: true},
|
||||
{name: "zero", maxPoolCount: 0},
|
||||
{name: "positive", maxPoolCount: 5},
|
||||
{name: "maximum int64", maxPoolCount: math.MaxInt64},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := validServerConfigWithAuth(v1.AuthServerConfig{Method: v1.AuthMethodToken})
|
||||
cfg.Transport.MaxPoolCount = tc.maxPoolCount
|
||||
require.NoError(t, cfg.Complete())
|
||||
|
||||
_, err := NewConfigValidator(nil).ValidateServerConfig(cfg)
|
||||
if tc.wantErr {
|
||||
require.ErrorContains(t, err, "invalid transport.maxPoolCount")
|
||||
require.ErrorContains(t, err, "must be non-negative")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
defer m.mu.Unlock()
|
||||
total = len(m.info.ProxyStatistics)
|
||||
for name, data := range m.info.ProxyStatistics {
|
||||
if !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||
delete(m.info.ProxyStatistics, name)
|
||||
count++
|
||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||
@@ -106,10 +104,20 @@ 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)
|
||||
}
|
||||
@@ -231,9 +239,11 @@ 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
|
||||
}
|
||||
@@ -243,8 +253,9 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
filterAll := proxyType == "" || proxyType == "all"
|
||||
for name, proxyStats := range m.info.ProxyStatistics {
|
||||
if proxyStats.ProxyType != proxyType {
|
||||
if !filterAll && proxyStats.ProxyType != proxyType {
|
||||
continue
|
||||
}
|
||||
res = append(res, toProxyStats(name, proxyStats))
|
||||
@@ -257,8 +268,13 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
||||
defer m.mu.Unlock()
|
||||
|
||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||
if ok && proxyStats.ProxyType == proxyType {
|
||||
res = toProxyStats(proxyName, proxyStats)
|
||||
if ok {
|
||||
// filterAll allows the "all proxies" API to look up a proxy by name without
|
||||
// knowing its type (proxyType == "" or "all").
|
||||
filterAll := proxyType == "" || proxyType == "all"
|
||||
if filterAll || proxyStats.ProxyType == proxyType {
|
||||
res = toProxyStats(proxyName, proxyStats)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||
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) {
|
||||
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
||||
TodayTrafficOut int64
|
||||
LastStartTime string
|
||||
LastCloseTime string
|
||||
LastStartAt int64
|
||||
LastCloseAt int64
|
||||
CurConns int64
|
||||
}
|
||||
|
||||
@@ -85,4 +87,5 @@ type Collector interface {
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
PruneOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ type NewProxy struct {
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
||||
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
||||
HTTPRedirect bool `json:"http_redirect,omitempty"`
|
||||
|
||||
// stcp, sudp, xtcp
|
||||
Sk string `json:"sk,omitempty"`
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
type udpBenchmarkCase struct {
|
||||
name string
|
||||
packet *UDPPacket
|
||||
}
|
||||
|
||||
var (
|
||||
udpBenchmarkBytesSink []byte
|
||||
udpBenchmarkMessageSink Message
|
||||
)
|
||||
|
||||
func udpBenchmarkCases(payloadSize int) []udpBenchmarkCase {
|
||||
content := bytes.Repeat([]byte{0x5a}, payloadSize)
|
||||
return []udpBenchmarkCase{
|
||||
{
|
||||
name: "ipv4-remote",
|
||||
packet: &UDPPacket{
|
||||
Content: content,
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ipv4-local-remote",
|
||||
packet: &UDPPacket{
|
||||
Content: content,
|
||||
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 23456},
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ipv6-remote",
|
||||
packet: &UDPPacket{
|
||||
Content: content,
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ipv6-local-remote",
|
||||
packet: &UDPPacket{
|
||||
Content: content,
|
||||
LocalAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0"},
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPPacketV2FrameSizes(t *testing.T) {
|
||||
t.Logf("environment go=%s goos=%s goarch=%s gomaxprocs=%d", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
|
||||
for _, payloadSize := range []int{64, 512, 1200, 1472} {
|
||||
for _, tc := range udpBenchmarkCases(payloadSize) {
|
||||
jsonFrame := udpBenchmarkWireBytes(t, tc.packet, "")
|
||||
binaryFrame := udpBenchmarkWireBytes(t, tc.packet, wire.UDPPacketCodecBinary)
|
||||
saving := 100 * float64(len(jsonFrame)-len(binaryFrame)) / float64(len(jsonFrame))
|
||||
t.Logf("frame payload=%d case=%s json_bytes=%d binary_bytes=%d binary_saving_pct=%.2f", payloadSize, tc.name, len(jsonFrame), len(binaryFrame), saving)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func udpBenchmarkWireBytes(t testing.TB, packet *UDPPacket, codec string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec)
|
||||
if err != nil {
|
||||
t.Fatalf("create UDP read writer: %v", err)
|
||||
}
|
||||
if err := rw.WriteMsg(packet); err != nil {
|
||||
t.Fatalf("write UDP packet: %v", err)
|
||||
}
|
||||
return append([]byte(nil), buf.Bytes()...)
|
||||
}
|
||||
|
||||
type udpBenchmarkReadWriter struct {
|
||||
reader bytes.Reader
|
||||
}
|
||||
|
||||
func (rw *udpBenchmarkReadWriter) Read(p []byte) (int, error) {
|
||||
return rw.reader.Read(p)
|
||||
}
|
||||
|
||||
func (rw *udpBenchmarkReadWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (rw *udpBenchmarkReadWriter) Reset(p []byte) {
|
||||
rw.reader.Reset(p)
|
||||
}
|
||||
|
||||
func udpBenchmarkValidatePacket(b testing.TB, got, want *UDPPacket) {
|
||||
b.Helper()
|
||||
if !bytes.Equal(got.Content, want.Content) || !udpBenchmarkUDPAddrEqual(got.LocalAddr, want.LocalAddr) ||
|
||||
!udpBenchmarkUDPAddrEqual(got.RemoteAddr, want.RemoteAddr) {
|
||||
b.Fatalf("decoded packet mismatch: got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func udpBenchmarkUDPAddrEqual(got, want *net.UDPAddr) bool {
|
||||
if got == nil || want == nil {
|
||||
return got == want
|
||||
}
|
||||
return got.IP.Equal(want.IP) && got.Port == want.Port && got.Zone == want.Zone
|
||||
}
|
||||
|
||||
func BenchmarkUDPPacketV2CodecWrite(b *testing.B) {
|
||||
for _, payloadSize := range []int{64, 512, 1200, 1472} {
|
||||
for _, tc := range udpBenchmarkCases(payloadSize) {
|
||||
for _, codec := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "json", value: ""},
|
||||
{name: "binary", value: wire.UDPPacketCodecBinary},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec.value)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
expected := udpBenchmarkWireBytes(b, tc.packet, codec.value)
|
||||
b.SetBytes(int64(len(expected)))
|
||||
for b.Loop() {
|
||||
buf.Reset()
|
||||
if err := rw.WriteMsg(tc.packet); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(buf.Bytes(), expected) {
|
||||
b.Fatalf("encoded packet mismatch: got %d bytes, want %d", buf.Len(), len(expected))
|
||||
}
|
||||
udpBenchmarkBytesSink = buf.Bytes()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUDPPacketV2CodecRead(b *testing.B) {
|
||||
for _, payloadSize := range []int{64, 512, 1200, 1472} {
|
||||
for _, tc := range udpBenchmarkCases(payloadSize) {
|
||||
for _, codec := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "json", value: ""},
|
||||
{name: "binary", value: wire.UDPPacketCodecBinary},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
|
||||
encoded := udpBenchmarkWireBytes(b, tc.packet, codec.value)
|
||||
stream := &udpBenchmarkReadWriter{}
|
||||
rw, err := NewUDPPacketReadWriter(stream, wire.ProtocolV2, codec.value)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
var decoded Message
|
||||
b.SetBytes(int64(len(encoded)))
|
||||
for b.Loop() {
|
||||
stream.Reset(encoded)
|
||||
decoded, err = rw.ReadMsg()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
packet, ok := decoded.(*UDPPacket)
|
||||
if !ok {
|
||||
b.Fatalf("decoded message type %T, want *UDPPacket", decoded)
|
||||
}
|
||||
udpBenchmarkValidatePacket(b, packet, tc.packet)
|
||||
udpBenchmarkMessageSink = decoded
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
const MaxUDPPayloadSize = 65507
|
||||
|
||||
const (
|
||||
udpPacketFlagLocalAddr byte = 1 << 0
|
||||
udpPacketFlagRemoteAddr byte = 1 << 1
|
||||
udpPacketValidFlags = udpPacketFlagLocalAddr | udpPacketFlagRemoteAddr
|
||||
)
|
||||
|
||||
type binaryUDPAddr struct {
|
||||
family byte
|
||||
ip []byte
|
||||
port uint16
|
||||
zone string
|
||||
}
|
||||
|
||||
// EncodeUDPPacketBinary encodes the body of a V2 binary UDP packet message.
|
||||
// RemoteAddr is required by the UDP forwarding path.
|
||||
func EncodeUDPPacketBinary(packet *UDPPacket) ([]byte, error) {
|
||||
if packet == nil {
|
||||
return nil, fmt.Errorf("nil UDP packet")
|
||||
}
|
||||
if packet.RemoteAddr == nil {
|
||||
return nil, fmt.Errorf("UDP packet missing remote address")
|
||||
}
|
||||
if len(packet.Content) > MaxUDPPayloadSize {
|
||||
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", len(packet.Content), MaxUDPPayloadSize)
|
||||
}
|
||||
|
||||
var flags byte
|
||||
var localAddr, remoteAddr binaryUDPAddr
|
||||
bodyLen := 1 + 2 + len(packet.Content)
|
||||
if packet.LocalAddr != nil {
|
||||
flags |= udpPacketFlagLocalAddr
|
||||
var err error
|
||||
localAddr, err = validateBinaryUDPAddr(packet.LocalAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local address: %w", err)
|
||||
}
|
||||
bodyLen += binaryUDPAddrLen(localAddr)
|
||||
}
|
||||
flags |= udpPacketFlagRemoteAddr
|
||||
var err error
|
||||
remoteAddr, err = validateBinaryUDPAddr(packet.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote address: %w", err)
|
||||
}
|
||||
bodyLen += binaryUDPAddrLen(remoteAddr)
|
||||
if 2+bodyLen > wire.DefaultMaxFramePayloadSize {
|
||||
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+bodyLen, wire.DefaultMaxFramePayloadSize)
|
||||
}
|
||||
|
||||
body := make([]byte, bodyLen)
|
||||
body[0] = flags
|
||||
offset := 1
|
||||
if flags&udpPacketFlagLocalAddr != 0 {
|
||||
offset = putBinaryUDPAddr(body, offset, localAddr)
|
||||
}
|
||||
offset = putBinaryUDPAddr(body, offset, remoteAddr)
|
||||
binary.BigEndian.PutUint16(body[offset:offset+2], uint16(len(packet.Content)))
|
||||
offset += 2
|
||||
copy(body[offset:], packet.Content)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// DecodeUDPPacketBinary decodes a V2 binary UDP packet body and returns data
|
||||
// that does not alias the input frame buffer.
|
||||
func DecodeUDPPacketBinary(body []byte) (*UDPPacket, error) {
|
||||
if len(body) < 3 {
|
||||
return nil, fmt.Errorf("UDP packet body too short: %d", len(body))
|
||||
}
|
||||
if 2+len(body) > wire.DefaultMaxFramePayloadSize {
|
||||
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+len(body), wire.DefaultMaxFramePayloadSize)
|
||||
}
|
||||
|
||||
flags := body[0]
|
||||
if flags&^udpPacketValidFlags != 0 {
|
||||
return nil, fmt.Errorf("reserved UDP packet flags set: 0x%02x", flags)
|
||||
}
|
||||
if flags&udpPacketFlagRemoteAddr == 0 {
|
||||
return nil, fmt.Errorf("UDP packet missing remote address")
|
||||
}
|
||||
|
||||
packet := &UDPPacket{}
|
||||
offset := 1
|
||||
var err error
|
||||
if flags&udpPacketFlagLocalAddr != 0 {
|
||||
packet.LocalAddr, offset, err = readBinaryUDPAddr(body, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local address: %w", err)
|
||||
}
|
||||
}
|
||||
if flags&udpPacketFlagRemoteAddr != 0 {
|
||||
packet.RemoteAddr, offset, err = readBinaryUDPAddr(body, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote address: %w", err)
|
||||
}
|
||||
}
|
||||
if len(body)-offset < 2 {
|
||||
return nil, fmt.Errorf("truncated UDP payload length")
|
||||
}
|
||||
payloadLen := int(binary.BigEndian.Uint16(body[offset : offset+2]))
|
||||
offset += 2
|
||||
if payloadLen > MaxUDPPayloadSize {
|
||||
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", payloadLen, MaxUDPPayloadSize)
|
||||
}
|
||||
remaining := len(body) - offset
|
||||
if remaining < payloadLen {
|
||||
return nil, fmt.Errorf("truncated UDP payload: have %d want %d", remaining, payloadLen)
|
||||
}
|
||||
if remaining > payloadLen {
|
||||
return nil, fmt.Errorf("trailing UDP packet bytes: %d", remaining-payloadLen)
|
||||
}
|
||||
packet.Content = append([]byte(nil), body[offset:offset+payloadLen]...)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func validateBinaryUDPAddr(addr *net.UDPAddr) (binaryUDPAddr, error) {
|
||||
if addr.Port < 0 || addr.Port > 65535 {
|
||||
return binaryUDPAddr{}, fmt.Errorf("port out of range: %d", addr.Port)
|
||||
}
|
||||
if ip := addr.IP.To4(); ip != nil {
|
||||
if addr.Zone != "" {
|
||||
return binaryUDPAddr{}, fmt.Errorf("IPv4 zone is forbidden")
|
||||
}
|
||||
return binaryUDPAddr{family: 4, ip: ip, port: uint16(addr.Port)}, nil
|
||||
}
|
||||
ip := addr.IP.To16()
|
||||
if ip == nil {
|
||||
return binaryUDPAddr{}, fmt.Errorf("invalid IP")
|
||||
}
|
||||
if len(addr.Zone) > 255 {
|
||||
return binaryUDPAddr{}, fmt.Errorf("zone exceeds 255 bytes")
|
||||
}
|
||||
if !utf8.ValidString(addr.Zone) {
|
||||
return binaryUDPAddr{}, fmt.Errorf("zone is not valid UTF-8")
|
||||
}
|
||||
return binaryUDPAddr{family: 6, ip: ip, port: uint16(addr.Port), zone: addr.Zone}, nil
|
||||
}
|
||||
|
||||
func binaryUDPAddrLen(addr binaryUDPAddr) int {
|
||||
return 1 + len(addr.ip) + 2 + 1 + len(addr.zone)
|
||||
}
|
||||
|
||||
func putBinaryUDPAddr(body []byte, offset int, addr binaryUDPAddr) int {
|
||||
body[offset] = addr.family
|
||||
offset++
|
||||
copy(body[offset:], addr.ip)
|
||||
offset += len(addr.ip)
|
||||
binary.BigEndian.PutUint16(body[offset:offset+2], addr.port)
|
||||
offset += 2
|
||||
body[offset] = byte(len(addr.zone))
|
||||
offset++
|
||||
copy(body[offset:], addr.zone)
|
||||
return offset + len(addr.zone)
|
||||
}
|
||||
|
||||
func readBinaryUDPAddr(body []byte, offset int) (*net.UDPAddr, int, error) {
|
||||
if offset >= len(body) {
|
||||
return nil, offset, fmt.Errorf("truncated address family")
|
||||
}
|
||||
family := body[offset]
|
||||
offset++
|
||||
var ipLen int
|
||||
switch family {
|
||||
case 4:
|
||||
ipLen = net.IPv4len
|
||||
case 6:
|
||||
ipLen = net.IPv6len
|
||||
default:
|
||||
return nil, offset, fmt.Errorf("unknown address family %d", family)
|
||||
}
|
||||
if len(body)-offset < ipLen+3 {
|
||||
return nil, offset, fmt.Errorf("truncated address")
|
||||
}
|
||||
ip := append(net.IP(nil), body[offset:offset+ipLen]...)
|
||||
offset += ipLen
|
||||
port := binary.BigEndian.Uint16(body[offset : offset+2])
|
||||
offset += 2
|
||||
zoneLen := int(body[offset])
|
||||
offset++
|
||||
if len(body)-offset < zoneLen {
|
||||
return nil, offset, fmt.Errorf("truncated zone")
|
||||
}
|
||||
zoneBytes := body[offset : offset+zoneLen]
|
||||
if family == 4 && zoneLen != 0 {
|
||||
return nil, offset, fmt.Errorf("IPv4 zone is forbidden")
|
||||
}
|
||||
if !utf8.Valid(zoneBytes) {
|
||||
return nil, offset, fmt.Errorf("zone is not valid UTF-8")
|
||||
}
|
||||
offset += zoneLen
|
||||
return &net.UDPAddr{IP: ip, Port: int(port), Zone: string(zoneBytes)}, offset, nil
|
||||
}
|
||||
|
||||
type V2BinaryUDPPacketReadWriter struct {
|
||||
conn *wire.Conn
|
||||
}
|
||||
|
||||
func NewV2BinaryUDPPacketReadWriter(rw io.ReadWriter) *V2BinaryUDPPacketReadWriter {
|
||||
return &V2BinaryUDPPacketReadWriter{conn: wire.NewConn(rw)}
|
||||
}
|
||||
|
||||
func (rw *V2BinaryUDPPacketReadWriter) ReadMsg() (Message, error) {
|
||||
frame, err := rw.conn.ReadFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isV2MessageType(frame, V2TypeUDPPacketBinary) {
|
||||
return decodeV2BinaryUDPPacketFrame(frame)
|
||||
}
|
||||
if isV2MessageType(frame, V2TypeUDPPacket) {
|
||||
return nil, fmt.Errorf("received JSON UDP packet after binary codec negotiation")
|
||||
}
|
||||
return DecodeV2MessageFrame(frame)
|
||||
}
|
||||
|
||||
func (rw *V2BinaryUDPPacketReadWriter) ReadMsgInto(out Message) error {
|
||||
frame, err := rw.conn.ReadFrame()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if packetOut, ok := out.(*UDPPacket); ok {
|
||||
if !isV2MessageType(frame, V2TypeUDPPacketBinary) {
|
||||
return unexpectedV2UDPPacketType(frame)
|
||||
}
|
||||
packet, err := decodeV2BinaryUDPPacketFrame(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*packetOut = *packet
|
||||
return nil
|
||||
}
|
||||
return DecodeV2MessageFrameInto(frame, out)
|
||||
}
|
||||
|
||||
func (rw *V2BinaryUDPPacketReadWriter) WriteMsg(message Message) error {
|
||||
var packet *UDPPacket
|
||||
switch typed := message.(type) {
|
||||
case *UDPPacket:
|
||||
packet = typed
|
||||
case UDPPacket:
|
||||
packet = &typed
|
||||
default:
|
||||
frame, err := EncodeV2MessageFrame(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rw.conn.WriteFrame(frame)
|
||||
}
|
||||
body, err := EncodeUDPPacketBinary(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := make([]byte, 2+len(body))
|
||||
binary.BigEndian.PutUint16(payload[:2], V2TypeUDPPacketBinary)
|
||||
copy(payload[2:], body)
|
||||
return rw.conn.WriteFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
|
||||
}
|
||||
|
||||
func decodeV2BinaryUDPPacketFrame(frame *wire.Frame) (*UDPPacket, error) {
|
||||
if frame.Type != wire.FrameTypeMessage {
|
||||
return nil, fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
|
||||
}
|
||||
if len(frame.Payload) < 2 {
|
||||
return nil, fmt.Errorf("message frame payload too short")
|
||||
}
|
||||
if binary.BigEndian.Uint16(frame.Payload[:2]) != V2TypeUDPPacketBinary {
|
||||
return nil, unexpectedV2UDPPacketType(frame)
|
||||
}
|
||||
return DecodeUDPPacketBinary(frame.Payload[2:])
|
||||
}
|
||||
|
||||
func isV2MessageType(frame *wire.Frame, typeID uint16) bool {
|
||||
return frame.Type == wire.FrameTypeMessage && len(frame.Payload) >= 2 && binary.BigEndian.Uint16(frame.Payload[:2]) == typeID
|
||||
}
|
||||
|
||||
func unexpectedV2UDPPacketType(frame *wire.Frame) error {
|
||||
if frame.Type != wire.FrameTypeMessage {
|
||||
return fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
|
||||
}
|
||||
if len(frame.Payload) < 2 {
|
||||
return fmt.Errorf("message frame payload too short")
|
||||
}
|
||||
typeID := binary.BigEndian.Uint16(frame.Payload[:2])
|
||||
if typeID == V2TypeUDPPacket {
|
||||
return fmt.Errorf("received JSON UDP packet after binary codec negotiation")
|
||||
}
|
||||
return fmt.Errorf("unexpected message type %d, want %d", typeID, V2TypeUDPPacketBinary)
|
||||
}
|
||||
|
||||
// NewUDPPacketReadWriter selects the negotiated packet codec without changing
|
||||
// the framing or codecs used by non-UDP messages on the work connection.
|
||||
func NewUDPPacketReadWriter(rw io.ReadWriter, wireProtocol, udpPacketCodec string) (ReadWriter, error) {
|
||||
switch wireProtocol {
|
||||
case "", wire.ProtocolV1:
|
||||
if udpPacketCodec != "" {
|
||||
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
|
||||
}
|
||||
return NewV1ReadWriter(rw), nil
|
||||
case wire.ProtocolV2:
|
||||
switch udpPacketCodec {
|
||||
case "":
|
||||
return NewV2ReadWriter(rw), nil
|
||||
case wire.UDPPacketCodecBinary:
|
||||
return NewV2BinaryUDPPacketReadWriter(rw), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported UDP packet codec %q", udpPacketCodec)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported wire protocol %q", wireProtocol)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestUDPPacketBinaryRoundTrip(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte{0xa5}, 1472)
|
||||
in := &UDPPacket{
|
||||
Content: payload,
|
||||
LocalAddr: &net.UDPAddr{
|
||||
IP: net.ParseIP("2001:db8::1"),
|
||||
Port: 1234,
|
||||
Zone: "en0",
|
||||
},
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
|
||||
}
|
||||
body, err := EncodeUDPPacketBinary(in)
|
||||
require.NoError(t, err)
|
||||
out, err := DecodeUDPPacketBinary(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, in.Content, out.Content)
|
||||
require.Equal(t, in.LocalAddr.String(), out.LocalAddr.String())
|
||||
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
|
||||
body[len(body)-1] ^= 0xff
|
||||
body[25] ^= 0xff
|
||||
require.Equal(t, byte(0xa5), out.Content[len(out.Content)-1], "decoded payload must own frame bytes")
|
||||
require.Equal(t, byte(203), out.RemoteAddr.IP.To4()[0], "decoded address must own frame bytes")
|
||||
}
|
||||
|
||||
func TestUDPPacketBinarySizesAndOptionalLocalAddress(t *testing.T) {
|
||||
for _, size := range []int{0, 32, 128, 512, 1200, 1472, 4096, 49107, 65507} {
|
||||
t.Run(strconv.Itoa(size), func(t *testing.T) {
|
||||
in := &UDPPacket{
|
||||
Content: bytes.Repeat([]byte{byte(size)}, size),
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
|
||||
}
|
||||
body, err := EncodeUDPPacketBinary(in)
|
||||
require.NoError(t, err)
|
||||
out, err := DecodeUDPPacketBinary(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(in.Content), len(out.Content))
|
||||
if size == 0 {
|
||||
require.Empty(t, out.Content)
|
||||
} else {
|
||||
require.Equal(t, in.Content, out.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPPacketBinaryMalformed(t *testing.T) {
|
||||
valid, err := EncodeUDPPacketBinary(&UDPPacket{
|
||||
Content: []byte("payload"),
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
tests := [][]byte{
|
||||
{0x80, 0, 0},
|
||||
{0x02, 4, 1, 2},
|
||||
{0x02, 4, 1, 2, 3, 4, 0xd4},
|
||||
append(append([]byte(nil), valid...), 0),
|
||||
}
|
||||
for _, malformed := range tests {
|
||||
_, err := DecodeUDPPacketBinary(malformed)
|
||||
require.Error(t, err)
|
||||
}
|
||||
_, err = DecodeUDPPacketBinary([]byte{0, 0, 0})
|
||||
require.ErrorContains(t, err, "missing remote address")
|
||||
payloadLengthOffset := len(valid) - len("payload") - 2
|
||||
invalidPayloadLength := append([]byte(nil), valid...)
|
||||
binary.BigEndian.PutUint16(invalidPayloadLength[payloadLengthOffset:payloadLengthOffset+2], 0xffff)
|
||||
_, err = DecodeUDPPacketBinary(invalidPayloadLength)
|
||||
require.ErrorContains(t, err, "payload length")
|
||||
truncatedPayload := append([]byte(nil), valid[:payloadLengthOffset+2]...)
|
||||
binary.BigEndian.PutUint16(truncatedPayload[payloadLengthOffset:payloadLengthOffset+2], 1)
|
||||
_, err = DecodeUDPPacketBinary(truncatedPayload)
|
||||
require.ErrorContains(t, err, "truncated UDP payload")
|
||||
_, err = DecodeUDPPacketBinary(make([]byte, wire.DefaultMaxFramePayloadSize))
|
||||
require.ErrorContains(t, err, "frame payload length")
|
||||
|
||||
badIPv4Zone := []byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 1, 'z', 0, 0}
|
||||
_, err = DecodeUDPPacketBinary(badIPv4Zone)
|
||||
require.ErrorContains(t, err, "IPv4 zone")
|
||||
badFamily := []byte{2, 9, 0, 0}
|
||||
_, err = DecodeUDPPacketBinary(badFamily)
|
||||
require.ErrorContains(t, err, "unknown address family")
|
||||
badUTF8 := make([]byte, 0, 24)
|
||||
badUTF8 = append(badUTF8, 2, 6)
|
||||
badUTF8 = append(badUTF8, make([]byte, 16)...)
|
||||
badUTF8 = append(badUTF8, 0, 1, 1, 0xff, 0, 0)
|
||||
_, err = DecodeUDPPacketBinary(badUTF8)
|
||||
require.ErrorContains(t, err, "UTF-8")
|
||||
}
|
||||
|
||||
func TestUDPPacketBinaryEncodeRejectsInvalidPackets(t *testing.T) {
|
||||
_, err := EncodeUDPPacketBinary(&UDPPacket{})
|
||||
require.ErrorContains(t, err, "missing remote address")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{
|
||||
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234},
|
||||
})
|
||||
require.ErrorContains(t, err, "missing remote address")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{
|
||||
Content: make([]byte, MaxUDPPayloadSize+1),
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
|
||||
})
|
||||
require.ErrorContains(t, err, "exceeds limit")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 1, Zone: "bad"}})
|
||||
require.ErrorContains(t, err, "IPv4 zone")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string(bytes.Repeat([]byte{'z'}, 256))}})
|
||||
require.ErrorContains(t, err, "zone exceeds")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string([]byte{0xff})}})
|
||||
require.ErrorContains(t, err, "UTF-8")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: -1}})
|
||||
require.ErrorContains(t, err, "port out of range")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: 65536}})
|
||||
require.ErrorContains(t, err, "port out of range")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.IP{1, 2, 3}}})
|
||||
require.ErrorContains(t, err, "invalid IP")
|
||||
_, err = EncodeUDPPacketBinary(&UDPPacket{
|
||||
Content: make([]byte, MaxUDPPayloadSize),
|
||||
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Zone: string(bytes.Repeat([]byte{'z'}, 255))},
|
||||
})
|
||||
require.ErrorContains(t, err, "frame payload length")
|
||||
}
|
||||
|
||||
func TestV2BinaryUDPPacketReadWriterPreservesOtherMessages(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rw := NewV2BinaryUDPPacketReadWriter(&buf)
|
||||
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
|
||||
require.NoError(t, rw.WriteMsg(in))
|
||||
require.NoError(t, rw.WriteMsg(&Ping{Timestamp: 7}))
|
||||
frameConn := wire.NewConn(&buf)
|
||||
frame, err := frameConn.ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
frame, err = frameConn.ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, V2TypePing, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
}
|
||||
|
||||
func TestV2BinaryUDPPacketReadWriterRoundTripAndCodecInvariant(t *testing.T) {
|
||||
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
|
||||
var binaryStream bytes.Buffer
|
||||
binaryWriter, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, binaryWriter.WriteMsg(in))
|
||||
binaryReader, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
|
||||
require.NoError(t, err)
|
||||
out, err := binaryReader.ReadMsg()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, in.Content, out.(*UDPPacket).Content)
|
||||
|
||||
for _, read := range []func(ReadWriter) error{
|
||||
func(rw ReadWriter) error {
|
||||
_, err := rw.ReadMsg()
|
||||
return err
|
||||
},
|
||||
func(rw ReadWriter) error {
|
||||
return rw.ReadMsgInto(&UDPPacket{})
|
||||
},
|
||||
} {
|
||||
var jsonStream bytes.Buffer
|
||||
require.NoError(t, NewReadWriter(&jsonStream, wire.ProtocolV2).WriteMsg(in))
|
||||
negotiatedReader, err := NewUDPPacketReadWriter(&jsonStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
|
||||
require.NoError(t, err)
|
||||
require.ErrorContains(t, read(negotiatedReader), "JSON UDP packet after binary codec negotiation")
|
||||
}
|
||||
|
||||
var fallbackStream bytes.Buffer
|
||||
fallbackWriter, err := NewUDPPacketReadWriter(&fallbackStream, wire.ProtocolV2, "")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fallbackWriter.WriteMsg(in))
|
||||
frame, err := wire.NewConn(&fallbackStream).ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
}
|
||||
|
||||
func TestNewUDPPacketReadWriterDefaultProtocolUsesV1(t *testing.T) {
|
||||
var stream bytes.Buffer
|
||||
rw, err := NewUDPPacketReadWriter(&stream, "", "")
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &V1ReadWriter{}, rw)
|
||||
require.NoError(t, rw.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
|
||||
require.Equal(t, TypeUDPPacket, stream.Bytes()[0])
|
||||
}
|
||||
|
||||
func TestNewUDPPacketReadWriterRejectsInvalidSelection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wireProtocol string
|
||||
udpPacketCodec string
|
||||
errorSubstring string
|
||||
}{
|
||||
{
|
||||
name: "binary codec over v1",
|
||||
wireProtocol: wire.ProtocolV1,
|
||||
udpPacketCodec: wire.UDPPacketCodecBinary,
|
||||
errorSubstring: "requires wire protocol v2",
|
||||
},
|
||||
{
|
||||
name: "binary codec over default protocol",
|
||||
udpPacketCodec: wire.UDPPacketCodecBinary,
|
||||
errorSubstring: "requires wire protocol v2",
|
||||
},
|
||||
{
|
||||
name: "unknown v2 codec",
|
||||
wireProtocol: wire.ProtocolV2,
|
||||
udpPacketCodec: "unknown",
|
||||
errorSubstring: "unsupported UDP packet codec",
|
||||
},
|
||||
{
|
||||
name: "unknown wire protocol",
|
||||
wireProtocol: "unknown",
|
||||
errorSubstring: "unsupported wire protocol",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rw, err := NewUDPPacketReadWriter(&bytes.Buffer{}, tc.wireProtocol, tc.udpPacketCodec)
|
||||
require.Nil(t, rw)
|
||||
require.ErrorContains(t, err, tc.errorSubstring)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzDecodeUDPPacketBinary(f *testing.F) {
|
||||
f.Add([]byte{0, 0, 0})
|
||||
f.Add([]byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 0, 0, 1})
|
||||
f.Fuzz(func(t *testing.T, body []byte) {
|
||||
_, _ = DecodeUDPPacketBinary(body)
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
V2TypeNatHoleResp uint16 = 16
|
||||
V2TypeNatHoleSid uint16 = 17
|
||||
V2TypeNatHoleReport uint16 = 18
|
||||
V2TypeUDPPacketBinary uint16 = 19
|
||||
)
|
||||
|
||||
var v2MsgTypeMap = map[uint16]any{
|
||||
|
||||
@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
|
||||
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)
|
||||
@@ -65,6 +84,9 @@ func TestV2MessageTypeIDsAreStable(t *testing.T) {
|
||||
require.Equal(t, uint16(16), V2TypeNatHoleResp)
|
||||
require.Equal(t, uint16(17), V2TypeNatHoleSid)
|
||||
require.Equal(t, uint16(18), V2TypeNatHoleReport)
|
||||
require.Equal(t, uint16(19), V2TypeUDPPacketBinary)
|
||||
_, registered := v2MsgTypeMap[V2TypeUDPPacketBinary]
|
||||
require.False(t, registered, "binary UDP has a dedicated codec and must not alter generic type registry")
|
||||
}
|
||||
|
||||
func TestV2MessageFrameEncoding(t *testing.T) {
|
||||
|
||||
@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
|
||||
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
||||
vResp, cResp, err := c.analysis(session)
|
||||
if err != nil {
|
||||
log.Debugf("sid [%s] analysis error: %v", err)
|
||||
log.Debugf("sid [%s] analysis error: %v", sid, err)
|
||||
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
||||
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
||||
}
|
||||
@@ -385,7 +385,6 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
||||
if !isLast {
|
||||
return nil
|
||||
}
|
||||
ports := make([]msg.PortsRange, 0, 1)
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -394,9 +393,8 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ports = append(ports, msg.PortsRange{
|
||||
return []msg.PortsRange{{
|
||||
From: max(port-difference-5, port-maxNumber, 1),
|
||||
To: min(port+difference+5, port+maxNumber, 65535),
|
||||
})
|
||||
return ports
|
||||
}}
|
||||
}
|
||||
|
||||
+25
-65
@@ -15,31 +15,24 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
)
|
||||
|
||||
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
|
||||
@@ -58,10 +51,9 @@ type stunResponse struct {
|
||||
}
|
||||
|
||||
type discoverConn struct {
|
||||
conn *net.UDPConn
|
||||
|
||||
localAddr net.Addr
|
||||
messageChan chan *Message
|
||||
conn *net.UDPConn
|
||||
client *stun.Client
|
||||
localAddr net.Addr
|
||||
}
|
||||
|
||||
func listen(localAddr string) (*discoverConn, error) {
|
||||
@@ -77,82 +69,50 @@ 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,
|
||||
localAddr: conn.LocalAddr(),
|
||||
messageChan: make(chan *Message, 10),
|
||||
conn: conn,
|
||||
client: client,
|
||||
localAddr: conn.LocalAddr(),
|
||||
}, 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
|
||||
}
|
||||
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
transaction, err := stun.NewBindingTransaction(serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = request.NewTransactionID(); err != nil {
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
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")
|
||||
}
|
||||
case <-time.After(responseTimeout):
|
||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||
return nil, err
|
||||
}
|
||||
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 response.MappedAddr != nil {
|
||||
resp.externalAddr = response.MappedAddr.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()
|
||||
if response.OtherAddr != nil {
|
||||
resp.otherAddr = response.OtherAddr.String()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
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)
|
||||
}
|
||||
@@ -18,10 +18,8 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
"github.com/pion/stun/v3"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
@@ -48,20 +46,6 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2026 The LoliaTeam Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !frps
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
)
|
||||
|
||||
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
|
||||
if auto == nil || !auto.Enable {
|
||||
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
|
||||
}
|
||||
|
||||
hostSet := make(map[string]struct{})
|
||||
hosts := make([]string, 0, len(auto.HostAllowList))
|
||||
addHost := func(host string) {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
if strings.Contains(host, "*") {
|
||||
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
|
||||
return
|
||||
}
|
||||
if _, ok := hostSet[host]; ok {
|
||||
return
|
||||
}
|
||||
hostSet[host] = struct{}{}
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
for _, host := range auto.HostAllowList {
|
||||
addHost(host)
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
for _, host := range fallbackHosts {
|
||||
addHost(host)
|
||||
}
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
|
||||
}
|
||||
|
||||
manager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Email: strings.TrimSpace(auto.Email),
|
||||
HostPolicy: autocert.HostWhitelist(hosts...),
|
||||
}
|
||||
caDirURL := strings.TrimSpace(auto.CADirURL)
|
||||
if caDirURL != "" {
|
||||
manager.Client = &acme.Client{DirectoryURL: caDirURL}
|
||||
} else {
|
||||
caDirURL = autocert.DefaultACMEDirectory
|
||||
}
|
||||
managedHosts := make(map[string]struct{}, len(hosts))
|
||||
for _, host := range hosts {
|
||||
managedHosts[host] = struct{}{}
|
||||
}
|
||||
var warmupInProgress sync.Map
|
||||
var warmupMissLogged sync.Map
|
||||
manager.Cache = &autoTLSCache{
|
||||
inner: autocert.DirCache(auto.CacheDir),
|
||||
managedHosts: managedHosts,
|
||||
pluginName: pluginName,
|
||||
caDirURL: caDirURL,
|
||||
warmupInProgress: &warmupInProgress,
|
||||
warmupMissLogged: &warmupMissLogged,
|
||||
}
|
||||
|
||||
cfg := manager.TLSConfig()
|
||||
log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir)
|
||||
|
||||
var readySeen sync.Map
|
||||
|
||||
handleCertReady := func(host string, cert *tls.Certificate) {
|
||||
var (
|
||||
notAfter time.Time
|
||||
hasExpiry bool
|
||||
)
|
||||
if t, ok := getCertificateNotAfter(cert); ok {
|
||||
notAfter = t
|
||||
hasExpiry = true
|
||||
}
|
||||
|
||||
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
|
||||
if hasExpiry {
|
||||
if !readyLogged {
|
||||
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
|
||||
}
|
||||
} else if !readyLogged {
|
||||
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
|
||||
if host == "" {
|
||||
host = "<空SNI>"
|
||||
}
|
||||
|
||||
cert, err := manager.GetCertificate(hello)
|
||||
if err != nil {
|
||||
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
|
||||
return nil, err
|
||||
}
|
||||
handleCertReady(host, cert)
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Warm up certificates in background after startup.
|
||||
for _, host := range hosts {
|
||||
h := host
|
||||
go func() {
|
||||
// Leave time for listener setup and route registration.
|
||||
time.Sleep(1 * time.Second)
|
||||
warmupMissLogged.Delete(h)
|
||||
warmupInProgress.Store(h, struct{}{})
|
||||
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
|
||||
warmupInProgress.Delete(h)
|
||||
if err != nil {
|
||||
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
|
||||
return
|
||||
}
|
||||
handleCertReady(h, cert)
|
||||
}()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
|
||||
if cert == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if cert.Leaf != nil {
|
||||
return cert.Leaf.NotAfter, true
|
||||
}
|
||||
if len(cert.Certificate) == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return leaf.NotAfter, true
|
||||
}
|
||||
|
||||
type autoTLSCache struct {
|
||||
inner autocert.Cache
|
||||
managedHosts map[string]struct{}
|
||||
pluginName string
|
||||
caDirURL string
|
||||
warmupInProgress *sync.Map
|
||||
warmupMissLogged *sync.Map
|
||||
}
|
||||
|
||||
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
data, err := c.inner.Get(ctx, key)
|
||||
if err != autocert.ErrCacheMiss {
|
||||
return data, err
|
||||
}
|
||||
|
||||
host := strings.TrimSuffix(key, "+rsa")
|
||||
if _, ok := c.managedHosts[host]; !ok {
|
||||
return data, err
|
||||
}
|
||||
if _, warming := c.warmupInProgress.Load(host); !warming {
|
||||
return data, err
|
||||
}
|
||||
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
|
||||
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-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)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 The LoliaTeam Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !frps
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
|
||||
}
|
||||
|
||||
type HTTP2HTTPSRedirectPlugin struct {
|
||||
opts *v1.HTTP2HTTPSRedirectPluginOptions
|
||||
|
||||
l *Listener
|
||||
s *http.Server
|
||||
}
|
||||
|
||||
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
|
||||
|
||||
listener := NewProxyListener()
|
||||
p := &HTTP2HTTPSRedirectPlugin{
|
||||
opts: opts,
|
||||
l: listener,
|
||||
}
|
||||
|
||||
p.s = &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// Not an open redirect: the target scheme is fixed to https and the
|
||||
// host is the one the client already connected to.
|
||||
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound) //nolint:gosec // G710
|
||||
}),
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = p.s.Serve(listener)
|
||||
}()
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
|
||||
host := strings.TrimSpace(req.Host)
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(req.URL.Host)
|
||||
}
|
||||
|
||||
targetHost := host
|
||||
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
|
||||
targetHost = parsedHost
|
||||
if httpsPort == 0 && parsedPort == "443" {
|
||||
httpsPort = 443
|
||||
}
|
||||
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||
}
|
||||
|
||||
if httpsPort != 0 && httpsPort != 443 {
|
||||
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
|
||||
}
|
||||
|
||||
return (&url.URL{
|
||||
Scheme: "https",
|
||||
Host: targetHost,
|
||||
Path: req.URL.Path,
|
||||
RawPath: req.URL.RawPath,
|
||||
RawQuery: req.URL.RawQuery,
|
||||
}).String()
|
||||
}
|
||||
|
||||
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
||||
if connInfo.SrcAddr != nil {
|
||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
||||
}
|
||||
_ = p.l.PutConn(wrapConn)
|
||||
}
|
||||
|
||||
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
|
||||
return v1.PluginHTTP2HTTPSRedirect
|
||||
}
|
||||
|
||||
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
|
||||
return p.s.Close()
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
stdlog "log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -80,11 +81,27 @@ func newHTTPSBridgePluginServer(
|
||||
enableHTTP2 *bool,
|
||||
useSourceRemoteAddr bool,
|
||||
) (*httpBridgePlugin, error) {
|
||||
listener := NewProxyListener()
|
||||
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
|
||||
}
|
||||
|
||||
// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
|
||||
// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
|
||||
func newHTTPSBridgePluginServerWithTLSConfig(
|
||||
handler http.Handler,
|
||||
tlsConfig *tls.Config,
|
||||
enableHTTP2 *bool,
|
||||
useSourceRemoteAddr bool,
|
||||
) *httpBridgePlugin {
|
||||
server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
|
||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
|
||||
}
|
||||
|
||||
func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
|
||||
listener := NewProxyListener()
|
||||
p := &httpBridgePlugin{
|
||||
l: listener,
|
||||
s: server,
|
||||
@@ -93,7 +110,7 @@ func newHTTPSBridgePluginServer(
|
||||
go func() {
|
||||
_ = p.s.ServeTLS(listener, "", "")
|
||||
}()
|
||||
return p, nil
|
||||
return p
|
||||
}
|
||||
|
||||
func newHTTPBridgeReverseProxy(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httputil"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
@@ -32,7 +33,7 @@ type HTTPS2HTTPPlugin struct {
|
||||
*httpBridgePlugin
|
||||
}
|
||||
|
||||
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||
|
||||
p := &HTTPS2HTTPPlugin{
|
||||
@@ -49,6 +50,15 @@ func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi
|
||||
nil,
|
||||
)
|
||||
|
||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
||||
}
|
||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -18,6 +18,7 @@ package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
|
||||
@@ -34,7 +35,7 @@ type HTTPS2HTTPSPlugin struct {
|
||||
*httpBridgePlugin
|
||||
}
|
||||
|
||||
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||
|
||||
p := &HTTPS2HTTPSPlugin{
|
||||
@@ -55,6 +56,15 @@ func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plug
|
||||
tr,
|
||||
)
|
||||
|
||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
||||
}
|
||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -33,7 +33,12 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||
}
|
||||
return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
|
||||
}
|
||||
|
||||
// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
|
||||
// It is used by features such as autoTLS that supply their own certificate provider.
|
||||
func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
|
||||
server := &http.Server{
|
||||
Handler: withMisdirectedRequestCheck(handler),
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
@@ -42,7 +47,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
||||
if !lo.FromPtr(enableHTTP2) {
|
||||
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
||||
}
|
||||
return server, nil
|
||||
return server
|
||||
}
|
||||
|
||||
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
type PluginContext struct {
|
||||
Name string
|
||||
HostAllowList []string
|
||||
VnetController *vnet.Controller
|
||||
}
|
||||
|
||||
|
||||
@@ -39,16 +39,25 @@ type TLS2RawPlugin struct {
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.TLS2RawPluginOptions)
|
||||
|
||||
p := &TLS2RawPlugin{
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var tlsConfig *tls.Config
|
||||
var err error
|
||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
p.tlsConfig = tlsConfig
|
||||
return p, nil
|
||||
@@ -72,6 +81,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
if connInfo.ProxyProtocolHeader != nil {
|
||||
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||
rawConn.Close()
|
||||
tlsConn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
libio.Join(tlsConn, rawConn)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goliblog "github.com/fatedier/golib/log"
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||
@@ -39,8 +39,8 @@ type testPlugin struct {
|
||||
var logCaptureMu sync.Mutex
|
||||
|
||||
type logCapture struct {
|
||||
bytes.Buffer
|
||||
levels []goliblog.Level
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (p testPlugin) Name() string {
|
||||
@@ -55,9 +55,36 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
|
||||
return p.handler(ctx, op, content)
|
||||
}
|
||||
|
||||
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
||||
w.levels = append(w.levels, level)
|
||||
return w.Write(p)
|
||||
func (w *logCapture) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func (w *logCapture) String() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.String()
|
||||
}
|
||||
|
||||
// levels parses the captured JSON log output and returns the level of each entry.
|
||||
func (w *logCapture) levels() []charmlog.Level {
|
||||
var levels []charmlog.Level
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(w.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var entry struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
|
||||
levels = append(levels, lvl)
|
||||
}
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
func captureLogOutput(t *testing.T) *logCapture {
|
||||
@@ -66,11 +93,10 @@ func captureLogOutput(t *testing.T) *logCapture {
|
||||
logCaptureMu.Lock()
|
||||
logOutput := &logCapture{}
|
||||
oldLogger := frplog.Logger
|
||||
frplog.Logger = goliblog.New(
|
||||
goliblog.WithOutput(logOutput),
|
||||
goliblog.WithLevel(goliblog.TraceLevel),
|
||||
goliblog.WithCaller(false),
|
||||
)
|
||||
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
|
||||
Level: charmlog.DebugLevel,
|
||||
Formatter: charmlog.JSONFormatter,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
frplog.Logger = oldLogger
|
||||
logCaptureMu.Unlock()
|
||||
@@ -263,10 +289,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op string
|
||||
level goliblog.Level
|
||||
level charmlog.Level
|
||||
}{
|
||||
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
||||
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
||||
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
|
||||
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -288,8 +314,9 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
||||
levels := logOutput.levels()
|
||||
if len(levels) != 1 || levels[0] != tt.level {
|
||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -325,12 +352,13 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
||||
t.Fatalf("missing aggregated errors: %v", err)
|
||||
}
|
||||
if len(logOutput.levels) != 2 {
|
||||
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
||||
levels := logOutput.levels()
|
||||
if len(levels) != 2 {
|
||||
t.Fatalf("expected two warning logs, got %v", levels)
|
||||
}
|
||||
for _, level := range logOutput.levels {
|
||||
if level != goliblog.WarnLevel {
|
||||
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
||||
for _, level := range levels {
|
||||
if level != charmlog.WarnLevel {
|
||||
t.Fatalf("expected warning log level, got %v", levels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,13 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
|
||||
// NewUDPPacket copies buf[:n], so the read buffer can be reused
|
||||
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
|
||||
|
||||
select {
|
||||
case sendCh <- udpMsg:
|
||||
default:
|
||||
if err = errors.PanicToError(func() {
|
||||
select {
|
||||
case sendCh <- udpMsg:
|
||||
default:
|
||||
}
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
func TestUdpPacket(t *testing.T) {
|
||||
@@ -16,3 +20,33 @@ func TestUdpPacket(t *testing.T) {
|
||||
require.NoError(err)
|
||||
require.EqualValues(buf, newBuf)
|
||||
}
|
||||
|
||||
func TestForwardUserConnReturnsWhenSendChannelIsClosed(t *testing.T) {
|
||||
listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
|
||||
readCh := make(chan *msg.UDPPacket)
|
||||
sendCh := make(chan *msg.UDPPacket)
|
||||
close(sendCh)
|
||||
t.Cleanup(func() { close(readCh) })
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ForwardUserConn(listener, readCh, sendCh, 1500)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
sender, err := net.DialUDP("udp4", nil, listener.LocalAddr().(*net.UDPAddr))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sender.Close() })
|
||||
|
||||
_, err = sender.Write([]byte("trigger"))
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ForwardUserConn did not return after sending to a closed channel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) {
|
||||
return ServerHello{
|
||||
Selected: ServerSelection{
|
||||
Message: MessageSelection{
|
||||
Codec: MessageCodecJSON,
|
||||
Codec: MessageCodecJSON,
|
||||
UDPPacketCodec: selectUDPPacketCodec(clientHello.Capabilities.Message.UDPPacketCodecs),
|
||||
},
|
||||
Crypto: CryptoSelection{
|
||||
Algorithm: algorithm,
|
||||
@@ -92,6 +93,15 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
|
||||
if serverHello.Selected.Message.Codec != MessageCodecJSON {
|
||||
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
|
||||
}
|
||||
udpPacketCodec := serverHello.Selected.Message.UDPPacketCodec
|
||||
if udpPacketCodec != "" {
|
||||
if udpPacketCodec != UDPPacketCodecBinary {
|
||||
return fmt.Errorf("unsupported selected UDP packet codec: %s", udpPacketCodec)
|
||||
}
|
||||
if !Supports(clientHello.Capabilities.Message.UDPPacketCodecs, udpPacketCodec) {
|
||||
return fmt.Errorf("selected UDP packet codec was not advertised by client: %s", udpPacketCodec)
|
||||
}
|
||||
}
|
||||
cryptoSelection := serverHello.Selected.Crypto
|
||||
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
|
||||
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
|
||||
@@ -105,6 +115,13 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectUDPPacketCodec(codecs []string) string {
|
||||
if Supports(codecs, UDPPacketCodecBinary) {
|
||||
return UDPPacketCodecBinary
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
|
||||
return &CryptoContext{
|
||||
Algorithm: algorithm,
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
FrameTypeMessage uint16 = 16
|
||||
|
||||
MessageCodecJSON = "json"
|
||||
UDPPacketCodecBinary = "binary-v1"
|
||||
DefaultMaxFramePayloadSize = 64 * 1024
|
||||
|
||||
MagicV2 = "FRP\x00\x02\r\n"
|
||||
@@ -182,7 +183,8 @@ type ClientCapabilities struct {
|
||||
}
|
||||
|
||||
type MessageCapabilities struct {
|
||||
Codecs []string `json:"codecs,omitempty"`
|
||||
Codecs []string `json:"codecs,omitempty"`
|
||||
UDPPacketCodecs []string `json:"udpPacketCodecs,omitempty"`
|
||||
}
|
||||
|
||||
type CryptoCapabilities struct {
|
||||
@@ -201,7 +203,8 @@ type ServerSelection struct {
|
||||
}
|
||||
|
||||
type MessageSelection struct {
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
UDPPacketCodec string `json:"udpPacketCodec,omitempty"`
|
||||
}
|
||||
|
||||
type CryptoSelection struct {
|
||||
@@ -214,7 +217,8 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C
|
||||
Bootstrap: bootstrap,
|
||||
Capabilities: ClientCapabilities{
|
||||
Message: MessageCapabilities{
|
||||
Codecs: []string{MessageCodecJSON},
|
||||
Codecs: []string{MessageCodecJSON},
|
||||
UDPPacketCodecs: []string{UDPPacketCodecBinary},
|
||||
},
|
||||
Crypto: CryptoCapabilities{
|
||||
Algorithms: PreferredAEADAlgorithms(),
|
||||
|
||||
@@ -148,10 +148,40 @@ func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
|
||||
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
|
||||
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
|
||||
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
|
||||
}
|
||||
|
||||
func TestUDPPacketCodecNegotiationFallbackAndValidation(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
|
||||
require.NoError(t, ValidateServerHelloForClient(hello, serverHello))
|
||||
|
||||
legacyHello := hello
|
||||
legacyHello.Capabilities.Message.UDPPacketCodecs = nil
|
||||
legacyServerHello, err := NewServerHello(legacyHello)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, legacyServerHello.Selected.Message.UDPPacketCodec)
|
||||
require.NoError(t, ValidateServerHelloForClient(legacyHello, legacyServerHello))
|
||||
|
||||
unknownOffer := hello
|
||||
unknownOffer.Capabilities.Message.UDPPacketCodecs = []string{"unknown"}
|
||||
unknownServerHello, err := NewServerHello(unknownOffer)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, unknownServerHello.Selected.Message.UDPPacketCodec)
|
||||
|
||||
rejected := serverHello
|
||||
rejected.Selected.Message.UDPPacketCodec = "unknown"
|
||||
require.ErrorContains(t, ValidateServerHelloForClient(hello, rejected), "unsupported selected UDP packet codec")
|
||||
|
||||
unadvertised := serverHello
|
||||
unadvertised.Selected.Message.UDPPacketCodec = UDPPacketCodecBinary
|
||||
require.ErrorContains(t, ValidateServerHelloForClient(legacyHello, unadvertised), "was not advertised")
|
||||
}
|
||||
|
||||
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
serverHello, err := NewServerHello(hello)
|
||||
|
||||
+21
-5
@@ -16,7 +16,6 @@ package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -52,6 +51,11 @@ type tcpipForward struct {
|
||||
Port uint32
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc4254#section-6.5
|
||||
type execPayload struct {
|
||||
Command string
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc4254#page-16
|
||||
type forwardedTCPPayload struct {
|
||||
Addr string
|
||||
@@ -66,6 +70,7 @@ type TunnelServer struct {
|
||||
sshConn *ssh.ServerConn
|
||||
sc *ssh.ServerConfig
|
||||
firstChannel ssh.Channel
|
||||
firstChannelMu sync.Mutex
|
||||
|
||||
vc *virtual.Client
|
||||
peerServerListener *netpkg.InternalListener
|
||||
@@ -187,6 +192,8 @@ func (s *TunnelServer) Run() error {
|
||||
}
|
||||
|
||||
func (s *TunnelServer) writeToClient(data string) {
|
||||
s.firstChannelMu.Lock()
|
||||
defer s.firstChannelMu.Unlock()
|
||||
if s.firstChannel == nil {
|
||||
return
|
||||
}
|
||||
@@ -300,23 +307,24 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.firstChannelMu.Lock()
|
||||
if s.firstChannel == nil {
|
||||
s.firstChannel = ch
|
||||
}
|
||||
s.firstChannelMu.Unlock()
|
||||
go s.keepAlive(ch)
|
||||
|
||||
for req := range reqs {
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
if req.Type != "exec" || len(req.Payload) <= 4 {
|
||||
if req.Type != "exec" {
|
||||
continue
|
||||
}
|
||||
end := 4 + binary.BigEndian.Uint32(req.Payload[:4])
|
||||
if len(req.Payload) < int(end) {
|
||||
extraPayload, ok := parseExecPayload(req.Payload)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
extraPayload := string(req.Payload[4:end])
|
||||
select {
|
||||
case extraPayloadCh <- extraPayload:
|
||||
default:
|
||||
@@ -324,6 +332,14 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
||||
}
|
||||
}
|
||||
|
||||
func parseExecPayload(payload []byte) (string, bool) {
|
||||
var msg execPayload
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return msg.Command, true
|
||||
}
|
||||
|
||||
func (s *TunnelServer) keepAlive(ch ssh.Channel) {
|
||||
tk := time.NewTicker(time.Second * 30)
|
||||
defer tk.Stop()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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 ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestParseExecPayload(t *testing.T) {
|
||||
payload := cryptossh.Marshal(&execPayload{Command: "tcp --remote_port 6000"})
|
||||
|
||||
got, ok := parseExecPayload(payload)
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "tcp --remote_port 6000", got)
|
||||
}
|
||||
|
||||
func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) {
|
||||
overflowLength := make([]byte, 5)
|
||||
binary.BigEndian.PutUint32(overflowLength[:4], ^uint32(0))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
payload []byte
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
payload: nil,
|
||||
},
|
||||
{
|
||||
name: "short length prefix",
|
||||
payload: []byte{0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "declared length exceeds remaining payload",
|
||||
payload: []byte{0, 0, 0, 2, 'x'},
|
||||
},
|
||||
{
|
||||
name: "overflow length",
|
||||
payload: overflowLength,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
got string
|
||||
ok bool
|
||||
)
|
||||
require.NotPanics(t, func() {
|
||||
got, ok = parseExecPayload(tc.payload)
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type trackingChannel struct {
|
||||
active atomic.Int32
|
||||
concurrent atomic.Bool
|
||||
}
|
||||
|
||||
func (c *trackingChannel) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
|
||||
func (c *trackingChannel) Write(p []byte) (int, error) {
|
||||
if c.active.Add(1) != 1 {
|
||||
c.concurrent.Store(true)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
c.active.Add(-1)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *trackingChannel) Close() error { return nil }
|
||||
func (c *trackingChannel) CloseWrite() error { return nil }
|
||||
func (c *trackingChannel) SendRequest(string, bool, []byte) (bool, error) { return false, nil }
|
||||
func (c *trackingChannel) Stderr() io.ReadWriter { return nil }
|
||||
|
||||
func TestWriteToClientSerializesChannelWrites(t *testing.T) {
|
||||
channel := &trackingChannel{}
|
||||
s := &TunnelServer{firstChannel: channel}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
s.writeToClient("message")
|
||||
})
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if channel.concurrent.Load() {
|
||||
t.Fatal("channel writes were concurrent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package banner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
"github.com/fatedier/frp/pkg/util/version"
|
||||
)
|
||||
|
||||
func DisplayBanner() {
|
||||
fmt.Println(" __ ___ __________ ____ ________ ____")
|
||||
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
|
||||
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
|
||||
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
|
||||
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
|
||||
fmt.Println(" ")
|
||||
log.Infof("Nya! %s 启动中", version.Full())
|
||||
}
|
||||
@@ -26,6 +26,12 @@ type GeneralResponse struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
type V2Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// APIHandler is a handler function that returns a response object or an error.
|
||||
type APIHandler func(ctx *Context) (any, error)
|
||||
|
||||
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
||||
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := NewContext(w, r)
|
||||
res, err := handler(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||
code := http.StatusInternalServerError
|
||||
if e, ok := err.(*Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package limit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// NewBandwidthLimiter creates a limiter whose rate preserves the configured
|
||||
// byte limit while keeping the burst representable as an int on all targets.
|
||||
func NewBandwidthLimiter(bytes int64) *rate.Limiter {
|
||||
if bytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
burst := min(bytes, maxInt)
|
||||
return rate.NewLimiter(rate.Limit(float64(bytes)), int(burst))
|
||||
}
|
||||
|
||||
func invalidBurstError(burst int) error {
|
||||
return fmt.Errorf("invalid limiter burst: %d", burst)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package limit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func TestNewBandwidthLimiterClampsBurstToTargetInt(t *testing.T) {
|
||||
const bytesPerSecond = int64(1 << 31)
|
||||
|
||||
limiter := NewBandwidthLimiter(bytesPerSecond)
|
||||
require.NotNil(t, limiter)
|
||||
|
||||
wantBurst := bytesPerSecond
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if wantBurst > maxInt {
|
||||
wantBurst = maxInt
|
||||
}
|
||||
require.Equal(t, int(wantBurst), limiter.Burst())
|
||||
require.Equal(t, rate.Limit(float64(bytesPerSecond)), limiter.Limit())
|
||||
}
|
||||
|
||||
func TestNewBandwidthLimiterDisablesNonPositiveLimit(t *testing.T) {
|
||||
require.Nil(t, NewBandwidthLimiter(0))
|
||||
require.Nil(t, NewBandwidthLimiter(-1))
|
||||
}
|
||||
|
||||
func TestReaderAndWriterRejectInvalidBurst(t *testing.T) {
|
||||
for _, burst := range []int{0, -1} {
|
||||
t.Run("reader/"+strconv.Itoa(burst), func(t *testing.T) {
|
||||
reader := NewReader(strings.NewReader("payload"), rate.NewLimiter(rate.Limit(1), burst))
|
||||
n, err := reader.Read(make([]byte, 1))
|
||||
require.Zero(t, n)
|
||||
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
|
||||
})
|
||||
|
||||
t.Run("writer/"+strconv.Itoa(burst), func(t *testing.T) {
|
||||
var dst bytes.Buffer
|
||||
writer := NewWriter(&dst, rate.NewLimiter(rate.Limit(1), burst))
|
||||
n, err := writer.Write([]byte("payload"))
|
||||
require.Zero(t, n)
|
||||
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
|
||||
require.Empty(t, dst.Bytes())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ func NewReader(r io.Reader, limiter *rate.Limiter) *Reader {
|
||||
|
||||
func (r *Reader) Read(p []byte) (n int, err error) {
|
||||
b := r.limiter.Burst()
|
||||
if b <= 0 {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, invalidBurstError(b)
|
||||
}
|
||||
if b < len(p) {
|
||||
p = p[:b]
|
||||
}
|
||||
|
||||
@@ -34,8 +34,15 @@ func NewWriter(w io.Writer, limiter *rate.Limiter) *Writer {
|
||||
}
|
||||
|
||||
func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var nn int
|
||||
b := w.limiter.Burst()
|
||||
if b <= 0 {
|
||||
return 0, invalidBurstError(b)
|
||||
}
|
||||
for {
|
||||
end := len(p)
|
||||
if end == 0 {
|
||||
|
||||
+157
-27
@@ -16,13 +16,18 @@ package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/log"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
var (
|
||||
TraceLevel = log.TraceLevel
|
||||
TraceLevel = log.DebugLevel
|
||||
DebugLevel = log.DebugLevel
|
||||
InfoLevel = log.InfoLevel
|
||||
WarnLevel = log.WarnLevel
|
||||
@@ -32,39 +37,157 @@ var (
|
||||
var Logger *log.Logger
|
||||
|
||||
func init() {
|
||||
Logger = log.New(
|
||||
log.WithCaller(true),
|
||||
log.AddCallerSkip(1),
|
||||
log.WithLevel(log.InfoLevel),
|
||||
)
|
||||
Logger = log.NewWithOptions(os.Stderr, log.Options{
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "LoliaFRP-CLI",
|
||||
CallerOffset: 1,
|
||||
})
|
||||
// 设置自定义样式以支持 Trace 级别
|
||||
styles := log.DefaultStyles()
|
||||
styles.Levels[TraceLevel] = lipgloss.NewStyle().
|
||||
SetString("TRACE").
|
||||
Bold(true).
|
||||
MaxWidth(5).
|
||||
Foreground(lipgloss.Color("61"))
|
||||
Logger.SetStyles(styles)
|
||||
}
|
||||
|
||||
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
||||
options := []log.Option{}
|
||||
var output io.Writer
|
||||
var err error
|
||||
|
||||
if logPath == "console" {
|
||||
if !disableLogColor {
|
||||
options = append(options,
|
||||
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
|
||||
Colorful: true,
|
||||
}, os.Stdout)),
|
||||
)
|
||||
}
|
||||
output = os.Stdout
|
||||
} else {
|
||||
writer := log.NewRotateFileWriter(log.RotateFileConfig{
|
||||
FileName: logPath,
|
||||
Mode: log.RotateFileModeDaily,
|
||||
MaxDays: maxDays,
|
||||
})
|
||||
writer.Init()
|
||||
options = append(options, log.WithOutput(writer))
|
||||
// Use rotating file writer
|
||||
output, err = NewRotateFileWriter(logPath, maxDays)
|
||||
if err != nil {
|
||||
// Fallback to console if file creation fails
|
||||
output = os.Stdout
|
||||
}
|
||||
}
|
||||
|
||||
level, err := log.ParseLevel(levelStr)
|
||||
if err != nil {
|
||||
level = log.InfoLevel
|
||||
}
|
||||
options = append(options, log.WithLevel(level))
|
||||
Logger = Logger.WithOptions(options...)
|
||||
|
||||
Logger = log.NewWithOptions(output, log.Options{
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "LoliaFRP-CLI",
|
||||
CallerOffset: 1,
|
||||
Level: level,
|
||||
})
|
||||
}
|
||||
|
||||
// NewRotateFileWriter creates a rotating file writer
|
||||
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
|
||||
w := &RotateFileWriter{
|
||||
filePath: filePath,
|
||||
maxDays: maxDays,
|
||||
lastRotate: time.Now(),
|
||||
currentDate: time.Now().Format("2006-01-02"),
|
||||
}
|
||||
|
||||
if err := w.openFile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// RotateFileWriter implements io.Writer with daily rotation
|
||||
type RotateFileWriter struct {
|
||||
filePath string
|
||||
maxDays int
|
||||
file *os.File
|
||||
lastRotate time.Time
|
||||
currentDate string
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) openFile() error {
|
||||
var err error
|
||||
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) checkRotate() error {
|
||||
now := time.Now()
|
||||
currentDate := now.Format("2006-01-02")
|
||||
|
||||
if currentDate != w.currentDate {
|
||||
// Close current file
|
||||
if w.file != nil {
|
||||
w.file.Close()
|
||||
}
|
||||
|
||||
// Rename current file with date suffix
|
||||
oldPath := w.filePath
|
||||
newPath := w.filePath + "." + w.currentDate
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old log files
|
||||
w.cleanupOldLogs(now)
|
||||
|
||||
// Update current date and open new file
|
||||
w.currentDate = currentDate
|
||||
w.lastRotate = now
|
||||
return w.openFile()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
|
||||
if w.maxDays <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cutoffDate := now.AddDate(0, 0, -w.maxDays)
|
||||
|
||||
// Find and remove old log files
|
||||
dir := filepath.Dir(w.filePath)
|
||||
base := filepath.Base(w.filePath)
|
||||
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := f.Name()
|
||||
// Extract date from filename (base.YYYY-MM-DD)
|
||||
if dateStr, ok := strings.CutPrefix(name, base+"."); ok {
|
||||
if len(dateStr) == 10 {
|
||||
fileDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err == nil && fileDate.Before(cutoffDate) {
|
||||
os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) Write(p []byte) (n int, err error) {
|
||||
if err := w.checkRotate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.file.Write(p)
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) Close() error {
|
||||
if w.file != nil {
|
||||
return w.file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Errorf(format string, v ...any) {
|
||||
@@ -75,6 +198,10 @@ func Warnf(format string, v ...any) {
|
||||
Logger.Warnf(format, v...)
|
||||
}
|
||||
|
||||
func Info(format string, v ...any) {
|
||||
Logger.Info(format, v...)
|
||||
}
|
||||
|
||||
func Infof(format string, v ...any) {
|
||||
Logger.Infof(format, v...)
|
||||
}
|
||||
@@ -84,11 +211,12 @@ func Debugf(format string, v ...any) {
|
||||
}
|
||||
|
||||
func Tracef(format string, v ...any) {
|
||||
Logger.Tracef(format, v...)
|
||||
Logger.Logf(TraceLevel, format, v...)
|
||||
}
|
||||
|
||||
func Logf(level log.Level, offset int, format string, v ...any) {
|
||||
Logger.Logf(level, offset, format, v...)
|
||||
// charmbracelet/log doesn't support offset, so we ignore it
|
||||
Logger.Logf(level, format, v...)
|
||||
}
|
||||
|
||||
type WriteLogger struct {
|
||||
@@ -104,6 +232,8 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
|
||||
}
|
||||
|
||||
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
||||
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
|
||||
// charmbracelet/log doesn't support offset in Log
|
||||
msg := string(bytes.TrimRight(p, "\n"))
|
||||
Logger.Log(w.level, msg)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package net
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -25,7 +26,6 @@ import (
|
||||
|
||||
libcrypto "github.com/fatedier/golib/crypto"
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
@@ -335,11 +335,6 @@ func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte)
|
||||
}
|
||||
|
||||
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
||||
info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
|
||||
reader := hkdf.New(sha256.New, key, transcriptHash, info)
|
||||
out := make([]byte, libcrypto.AEADKeySize)
|
||||
if _, err := io.ReadFull(reader, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
info := aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction
|
||||
return hkdf.Key(sha256.New, key, transcriptHash, info, libcrypto.AEADKeySize)
|
||||
}
|
||||
|
||||
@@ -114,5 +114,11 @@ func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
|
||||
bytes.Repeat([]byte{0x44}, 32),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{
|
||||
0xa0, 0x58, 0xcd, 0x02, 0x5d, 0x96, 0x98, 0x5f,
|
||||
0xeb, 0xeb, 0xff, 0x79, 0xa1, 0x9f, 0x62, 0xb7,
|
||||
0x15, 0xe0, 0x53, 0x91, 0x3d, 0xfc, 0x74, 0x77,
|
||||
0x05, 0x91, 0x4c, 0x62, 0x4b, 0xf3, 0xd4, 0x95,
|
||||
}, clientToServerKey)
|
||||
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
conn.PayloadType = websocket.BinaryFrame
|
||||
return ctx, conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@ package net
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SetDefaultDNSAddress(dnsAddress string) {
|
||||
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
|
||||
if strings.HasPrefix(dnsAddress, "https://") {
|
||||
SetDefaultDNSOverHTTPS(dnsAddress)
|
||||
return
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
||||
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2026 The Lolia Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package net
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dohMimeType = "application/dns-message"
|
||||
dohMaxResponseSize = 65535
|
||||
dohRequestTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends
|
||||
// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint,
|
||||
// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query".
|
||||
func SetDefaultDNSOverHTTPS(dohURL string) {
|
||||
client := &http.Client{
|
||||
Timeout: dohRequestTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: dohRequestTimeout,
|
||||
// Use a fresh resolver to look up the DoH server hostname itself,
|
||||
// avoiding infinite recursion through net.DefaultResolver.
|
||||
Resolver: &net.Resolver{},
|
||||
}).DialContext,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
}
|
||||
net.DefaultResolver = &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return &dohConn{
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
url: dohURL,
|
||||
network: network,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dohConn adapts the DNS wire-format messages exchanged by net.Resolver
|
||||
// into DNS-over-HTTPS requests. Since dohConn does not implement
|
||||
// net.PacketConn, the resolver always uses stream (TCP-style) framing with a
|
||||
// 2-byte big-endian length prefix, regardless of the dialed network. The
|
||||
// resolver writes a query and then reads the response from the same
|
||||
// connection; the HTTP round trip happens synchronously inside Write.
|
||||
type dohConn struct {
|
||||
ctx context.Context
|
||||
client *http.Client
|
||||
url string
|
||||
network string
|
||||
|
||||
mu sync.Mutex
|
||||
deadline time.Time
|
||||
reqBuf bytes.Buffer // unprocessed request bytes
|
||||
respBuf bytes.Buffer // response stream with 2-byte length prefixes
|
||||
}
|
||||
|
||||
func (c *dohConn) Write(b []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.reqBuf.Write(b)
|
||||
for {
|
||||
data := c.reqBuf.Bytes()
|
||||
if len(data) < 2 {
|
||||
break
|
||||
}
|
||||
msgLen := int(binary.BigEndian.Uint16(data))
|
||||
if len(data) < 2+msgLen {
|
||||
break
|
||||
}
|
||||
msg := make([]byte, msgLen)
|
||||
copy(msg, data[2:2+msgLen])
|
||||
c.reqBuf.Next(2 + msgLen)
|
||||
|
||||
resp, err := c.roundTrip(msg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var lenBuf [2]byte
|
||||
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp)))
|
||||
c.respBuf.Write(lenBuf[:])
|
||||
c.respBuf.Write(resp)
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *dohConn) Read(b []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.respBuf.Len() == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return c.respBuf.Read(b)
|
||||
}
|
||||
|
||||
func (c *dohConn) roundTrip(query []byte) ([]byte, error) {
|
||||
ctx := c.ctx
|
||||
if !c.deadline.IsZero() {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithDeadline(ctx, c.deadline)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", dohMimeType)
|
||||
req.Header.Set("Accept", dohMimeType)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > dohMaxResponseSize {
|
||||
return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *dohConn) Close() error { return nil }
|
||||
|
||||
func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} }
|
||||
func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} }
|
||||
|
||||
func (c *dohConn) SetDeadline(t time.Time) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.deadline = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
|
||||
func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) }
|
||||
|
||||
type dohAddr struct {
|
||||
network string
|
||||
addr string
|
||||
}
|
||||
|
||||
func (a *dohAddr) Network() string { return a.network }
|
||||
func (a *dohAddr) String() string { return a.addr }
|
||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
|
||||
muxer := http.NewServeMux()
|
||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
c.PayloadType = websocket.BinaryFrame
|
||||
notifyCh := make(chan struct{})
|
||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||
close(notifyCh)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user