forked from Mxmilu666/frp
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a73e8a14fc | ||
|
|
69ec4f0145 | ||
|
|
b0bf32571a | ||
|
|
f13fdd58d4 | ||
|
|
00e4fc4dd3 | ||
|
|
585fdc5b83 | ||
|
|
228d23b3d5 | ||
|
|
399bf2ce4c | ||
|
|
26742060b1 | ||
|
|
7441443134 | ||
|
|
954f918d0c | ||
|
|
031716afd1 | ||
|
|
41ed2aecf8 | ||
|
|
12b0093ccf | ||
|
|
2542d11a62 | ||
|
|
c4d2db4177 | ||
|
|
71b31978b3 | ||
|
|
1cc2dd8a23 | ||
|
|
9c7d9898b3 | ||
|
|
ab0959868a | ||
|
|
1dbd44edd3 | ||
|
|
fccc7b0e9f | ||
|
|
55107d003d | ||
|
|
4cd4747525 | ||
|
|
f8be1cdfe2 | ||
|
|
d5636108e6 | ||
|
|
d20a232996 | ||
|
|
b0d840858f | ||
|
|
c5508627c4 | ||
|
|
832df8dff6 | ||
|
|
ebb4773c2c | ||
|
|
23ec9b4979 | ||
|
|
40bb45d192 | ||
|
|
758f07d59e | ||
|
|
25a4ba306a | ||
|
|
6c8a8d0a97 | ||
|
|
da04e1e07a | ||
|
|
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 | ||
|
|
2886393f5b | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
68509f5d44 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
7fe152e3aa | ||
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec |
+13
-11
@@ -1,24 +1,26 @@
|
|||||||
version: 2
|
version: 2.1
|
||||||
jobs:
|
jobs:
|
||||||
go-version-latest:
|
go-version-latest:
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/go:1.25-node
|
- image: cimg/go:1.26-node
|
||||||
resource_class: large
|
resource_class: large
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- run:
|
- run:
|
||||||
name: Build web assets (frps)
|
name: Test and build web assets
|
||||||
command: make install build
|
command: make web-ci
|
||||||
working_directory: web/frps
|
|
||||||
- run:
|
- run:
|
||||||
name: Build web assets (frpc)
|
name: Check Go formatting and build binaries
|
||||||
command: make install build
|
command: |
|
||||||
working_directory: web/frpc
|
set -e
|
||||||
- run: make
|
make env fmt
|
||||||
- run: make alltest
|
git diff --exit-code
|
||||||
|
make build
|
||||||
|
- run:
|
||||||
|
name: Run all tests
|
||||||
|
command: make alltest
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
version: 2
|
|
||||||
build_and_test:
|
build_and_test:
|
||||||
jobs:
|
jobs:
|
||||||
- go-version-latest
|
- go-version-latest
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [fatedier]
|
||||||
|
custom: ["https://afdian.com/a/fatedier"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
### WHY
|
||||||
|
|
||||||
|
<!-- author to complete -->
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
name: Build FRP Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
goos: [linux, windows, darwin, freebsd, openbsd, android]
|
|
||||||
goarch: [amd64, 386, arm, arm64]
|
|
||||||
include:
|
|
||||||
- goos: linux
|
|
||||||
goarch: loong64
|
|
||||||
exclude:
|
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
- goos: darwin
|
|
||||||
goarch: 386
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm
|
|
||||||
- goos: openbsd
|
|
||||||
goarch: arm
|
|
||||||
- goos: android
|
|
||||||
goarch: amd64
|
|
||||||
- goos: android
|
|
||||||
goarch: 386
|
|
||||||
# 排除 Android ARM 32位,在单独的 job 中处理
|
|
||||||
- goos: android
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.22'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y zip tar make gcc g++ upx
|
|
||||||
|
|
||||||
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
|
|
||||||
run: |
|
|
||||||
mkdir -p release/packages
|
|
||||||
|
|
||||||
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
|
|
||||||
|
|
||||||
# 构建版本号
|
|
||||||
make
|
|
||||||
version=$(./bin/frps --version)
|
|
||||||
echo "Detected version: $version"
|
|
||||||
|
|
||||||
export GOOS=${{ matrix.goos }}
|
|
||||||
export GOARCH=${{ matrix.goarch }}
|
|
||||||
export CGO_ENABLED=0
|
|
||||||
|
|
||||||
# 构建可执行文件
|
|
||||||
make frpc frps
|
|
||||||
|
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
|
||||||
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
|
|
||||||
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
|
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
|
||||||
mv ./bin/frpc.exe "$out_dir/frpc.exe"
|
|
||||||
mv ./bin/frps.exe "$out_dir/frps.exe"
|
|
||||||
else
|
|
||||||
mv ./bin/frpc "$out_dir/frpc"
|
|
||||||
mv ./bin/frps "$out_dir/frps"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cp LICENSE "$out_dir"
|
|
||||||
cp -f conf/frpc.toml "$out_dir"
|
|
||||||
cp -f conf/frps.toml "$out_dir"
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
|
|
||||||
path: |
|
|
||||||
release/packages/frp_*
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
build-android-arm:
|
|
||||||
name: Build FRP android-arm
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.22'
|
|
||||||
|
|
||||||
- name: Install dependencies and Android NDK
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
|
|
||||||
|
|
||||||
# 下载并安装 Android NDK
|
|
||||||
echo "Downloading Android NDK..."
|
|
||||||
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
|
|
||||||
echo "Extracting Android NDK..."
|
|
||||||
unzip -q android-ndk-r26c-linux.zip
|
|
||||||
echo "NDK installed at: $PWD/android-ndk-r26c"
|
|
||||||
|
|
||||||
- name: Build FRP for android-arm
|
|
||||||
run: |
|
|
||||||
mkdir -p release/packages
|
|
||||||
mkdir -p bin
|
|
||||||
|
|
||||||
echo "Building for android-arm with CGO"
|
|
||||||
|
|
||||||
# 首先构建一次获取版本号
|
|
||||||
CGO_ENABLED=0 make
|
|
||||||
version=$(./bin/frps --version)
|
|
||||||
echo "Detected version: $version"
|
|
||||||
|
|
||||||
# 清理之前的构建
|
|
||||||
rm -rf ./bin/*
|
|
||||||
|
|
||||||
# 设置 Android ARM 交叉编译环境
|
|
||||||
export GOOS=android
|
|
||||||
export GOARCH=arm
|
|
||||||
export GOARM=7
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
|
|
||||||
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
|
|
||||||
|
|
||||||
echo "Environment:"
|
|
||||||
echo "GOOS=$GOOS"
|
|
||||||
echo "GOARCH=$GOARCH"
|
|
||||||
echo "GOARM=$GOARM"
|
|
||||||
echo "CGO_ENABLED=$CGO_ENABLED"
|
|
||||||
echo "CC=$CC"
|
|
||||||
|
|
||||||
# 直接使用 go build 命令,不通过 Makefile,防止 CGO_ENABLED 被覆盖
|
|
||||||
# -checklinkname=0: 关闭 Go 1.23+ 的 linkname 检查,规避 wlynxg/anet
|
|
||||||
# 在 android 下通过 //go:linkname 引用 net.zoneCache 导致的链接失败
|
|
||||||
echo "Building frps..."
|
|
||||||
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frps -o bin/frps ./cmd/frps
|
|
||||||
|
|
||||||
echo "Building frpc..."
|
|
||||||
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frpc -o bin/frpc ./cmd/frpc
|
|
||||||
|
|
||||||
# 验证文件已生成
|
|
||||||
ls -lh ./bin/
|
|
||||||
file ./bin/frpc
|
|
||||||
file ./bin/frps
|
|
||||||
|
|
||||||
out_dir="release/packages/frp_${version}_android_arm"
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
|
|
||||||
mv ./bin/frpc "$out_dir/frpc"
|
|
||||||
mv ./bin/frps "$out_dir/frps"
|
|
||||||
|
|
||||||
cp LICENSE "$out_dir"
|
|
||||||
cp -f conf/frpc.toml "$out_dir"
|
|
||||||
cp -f conf/frps.toml "$out_dir"
|
|
||||||
|
|
||||||
echo "Build completed for android-arm"
|
|
||||||
ls -lh "$out_dir"
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: LoliaFrp_android_arm
|
|
||||||
path: |
|
|
||||||
release/packages/frp_*
|
|
||||||
retention-days: 7
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Build Image and Publish to Dockerhub & GPR
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [ published ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Image tag'
|
||||||
|
required: true
|
||||||
|
default: 'test'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
name: Build Image from Dockerfile and binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# environment
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: '0'
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
# get image tag name
|
||||||
|
- name: Get Image Tag Name
|
||||||
|
run: |
|
||||||
|
if [ x${{ github.event.inputs.tag }} == x"" ]; then
|
||||||
|
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to the GPR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GPR_TOKEN }}
|
||||||
|
|
||||||
|
# prepare image tags
|
||||||
|
- name: Prepare Image Tags
|
||||||
|
run: |
|
||||||
|
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
|
||||||
|
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push frpc
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./dockerfiles/Dockerfile-for-frpc
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.TAG_FRPC }}
|
||||||
|
${{ env.TAG_FRPC_GPR }}
|
||||||
|
|
||||||
|
- name: Build and push frps
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./dockerfiles/Dockerfile-for-frps
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.TAG_FRPS }}
|
||||||
|
${{ env.TAG_FRPS_GPR }}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
name: Docker Build and Push
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: frps
|
|
||||||
dockerfile: dockerfiles/Dockerfile-for-frps
|
|
||||||
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
|
|
||||||
- name: frpc
|
|
||||||
dockerfile: dockerfiles/Dockerfile-for-frpc
|
|
||||||
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ matrix.image_name }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.dockerfile }}
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
|
||||||
|
|
||||||
- name: Generate image digest
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: |
|
|
||||||
echo "Image pushed to: ${{ matrix.image_name }}"
|
|
||||||
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
|
||||||
@@ -14,22 +14,18 @@ jobs:
|
|||||||
name: lint
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: false
|
cache: false
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- name: Test and build web assets
|
||||||
run: make build
|
run: make web-ci
|
||||||
working-directory: web/frps
|
|
||||||
- name: Build web assets (frpc)
|
|
||||||
run: make build
|
|
||||||
working-directory: web/frpc
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||||
version: v2.11
|
version: v2.12.2
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- name: Build web assets (frps)
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
name: Release FRP Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: "Tag to release (e.g., v1.0.0)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# 调用 build-all workflow
|
|
||||||
build:
|
|
||||||
uses: ./.github/workflows/build-all.yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
# 创建 release
|
|
||||||
release:
|
|
||||||
name: Create Release
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get tag name
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Display artifact structure
|
|
||||||
run: |
|
|
||||||
echo "Artifact structure:"
|
|
||||||
ls -R artifacts/
|
|
||||||
|
|
||||||
- name: Organize release files
|
|
||||||
run: |
|
|
||||||
mkdir -p release_files
|
|
||||||
|
|
||||||
# 查找并复制所有压缩包
|
|
||||||
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
|
|
||||||
|
|
||||||
# 如果没有压缩包,尝试查找二进制文件并打包
|
|
||||||
if [ -z "$(ls -A release_files/)" ]; then
|
|
||||||
echo "No archives found, looking for directories to package..."
|
|
||||||
for dir in artifacts/*/; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
artifact_name=$(basename "$dir")
|
|
||||||
echo "Packaging $artifact_name"
|
|
||||||
|
|
||||||
# 检查是否是 Windows 构建
|
|
||||||
if echo "$artifact_name" | grep -q "windows"; then
|
|
||||||
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
|
|
||||||
else
|
|
||||||
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Files in release_files:"
|
|
||||||
ls -lh release_files/
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
run: |
|
|
||||||
cd release_files
|
|
||||||
if [ -n "$(ls -A .)" ]; then
|
|
||||||
sha256sum * > sha256sum.txt
|
|
||||||
cat sha256sum.txt
|
|
||||||
else
|
|
||||||
echo "No files to generate checksums for!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Debug - Check tags and commits
|
|
||||||
run: |
|
|
||||||
echo "Current tag: ${{ steps.tag.outputs.tag }}"
|
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
|
|
||||||
echo "Previous tag: $PREV_TAG"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Commits between tags:"
|
|
||||||
if [ "$PREV_TAG" != "none" ]; then
|
|
||||||
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
|
|
||||||
else
|
|
||||||
echo "First tag, showing all commits:"
|
|
||||||
git log --oneline ${{ steps.tag.outputs.tag }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build Changelog
|
|
||||||
id: changelog
|
|
||||||
uses: requarks/changelog-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
tag: ${{ steps.tag.outputs.tag }}
|
|
||||||
writeToFile: false
|
|
||||||
includeInvalidCommits: true
|
|
||||||
useGitmojis: false
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.tag.outputs.tag }}
|
|
||||||
name: Release ${{ steps.tag.outputs.tag }}
|
|
||||||
body: ${{ steps.changelog.outputs.changes }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
files: |
|
|
||||||
release_files/*
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -32,10 +32,6 @@ node_modules/
|
|||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
CLAUDE.md
|
|
||||||
.claude/
|
.claude/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# TLS
|
|
||||||
.autotls-cache
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
export PATH := $(PATH):`go env GOPATH`/bin
|
export PATH := $(PATH):`go env GOPATH`/bin
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep),
|
LDFLAGS := -s -w
|
||||||
# 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')
|
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
||||||
FRP_COMPAT_BASELINE_COUNT ?= 8
|
FRP_COMPAT_BASELINE_COUNT ?= 8
|
||||||
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
|
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
|
all: env fmt web build
|
||||||
|
|
||||||
@@ -18,6 +16,9 @@ env:
|
|||||||
|
|
||||||
web: frps-web frpc-web
|
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:
|
frps-web:
|
||||||
$(MAKE) -C web/frps build
|
$(MAKE) -C web/frps build
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ all: build
|
|||||||
build: app
|
build: app
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@$(foreach n, $(os-archs), \
|
@set -e; $(foreach n, $(os-archs), \
|
||||||
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
||||||
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
||||||
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
[](https://circleci.com/gh/fatedier/frp)
|
[](https://circleci.com/gh/fatedier/frp)
|
||||||
[](https://github.com/fatedier/frp/releases)
|
[](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)
|
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||||
|
|
||||||
[README](README.md) | [中文文档](README_zh.md)
|
[README](README.md) | [中文文档](README_zh.md)
|
||||||
@@ -13,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Recall.ai - API for meeting recordings
|
||||||
|
|
||||||
|
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||||
|
|
||||||
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -31,15 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
|
||||||
|
|
||||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
|
||||||
|
|
||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
@@ -616,13 +616,31 @@ Proxies and visitors managed through the Store are saved to disk and automatical
|
|||||||
|
|
||||||
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
||||||
|
|
||||||
Prometheus is also supported.
|
Both frps and frpc support a separate `metricsServer` listener for health checks and optional profiling, without enabling the dashboard or admin API. It is disabled by default (`metricsServer.port = 0`) and binds to `127.0.0.1` unless configured otherwise.
|
||||||
|
|
||||||
|
`/healthz` returns an empty HTTP 200 response without authentication. It checks that the HTTP server is responding; it does not indicate whether frpc is connected to frps or whether proxies are ready.
|
||||||
|
|
||||||
#### Prometheus
|
#### Prometheus
|
||||||
|
|
||||||
Enable dashboard first, then configure `enablePrometheus = true` in `frps.toml`.
|
Configure a dedicated listener in `frps.toml`:
|
||||||
|
|
||||||
`http://{dashboard_addr}/metrics` will provide prometheus monitor data.
|
```toml
|
||||||
|
metricsServer.addr = "127.0.0.1"
|
||||||
|
metricsServer.port = 7501
|
||||||
|
metricsServer.user = "monitor"
|
||||||
|
metricsServer.password = "change-me"
|
||||||
|
# Optional TLS, independent of webServer:
|
||||||
|
# metricsServer.tls.certFile = "metrics.crt"
|
||||||
|
# metricsServer.tls.keyFile = "metrics.key"
|
||||||
|
# Profiling is disabled unless explicitly enabled:
|
||||||
|
# metricsServer.pprofEnable = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Scrape `http://127.0.0.1:7501/metrics` using these Basic Auth credentials. This automatically enables frps business metrics, even when `webServer` is disabled and `enablePrometheus` is unset. Credentials and TLS are independent of `webServer`; when credentials are configured, both `/metrics` and profiling endpoints require authentication. `/healthz` remains anonymous.
|
||||||
|
|
||||||
|
Set `metricsServer.pprofEnable = true` to expose `/debug/pprof/` and profiling endpoints on this listener. `/debug/pprof/cmdline` is excluded. frpc serves `/metrics` on this listener too, with Go runtime metrics only: no proxy, traffic or client data, and the same set of metrics on every platform.
|
||||||
|
|
||||||
|
The `/healthz`, `/metrics`, and pprof endpoints on `webServer` are deprecated but remain available. `enablePrometheus = true` still enables the legacy frps `/metrics` endpoint on the dashboard listener. Migrate health probes, Prometheus scrapes, and profiling to `metricsServer`; the dashboard and admin APIs continue to use `webServer`.
|
||||||
|
|
||||||
### Authenticating the Client
|
### Authenticating the Client
|
||||||
|
|
||||||
@@ -712,6 +730,10 @@ transport.tls.keyFile = "certificate.key"
|
|||||||
transport.tls.trustedCaFile = "ca.crt"
|
transport.tls.trustedCaFile = "ca.crt"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default, when neither `transport.tls.trustedCaFile` nor `transport.tls.useSystemCa` is set, `frpc` does not verify the server certificate. Set `transport.tls.useSystemCa = true` to verify it using the host's system CAs. If `trustedCaFile` is also set, its CA certificates are added to the system CA pool; setting only `trustedCaFile` trusts only the CAs in that file. CA loading or certificate verification failures cause the connection to fail without falling back to another trust source or skipping verification.
|
||||||
|
|
||||||
|
`useSystemCa` is supported only in `frpc`'s `transport.tls`, and requires `transport.tls.enable = true` unless `transport.protocol = "wss"`. With `wss`, the outer TLS connection is always enabled and verification applies to the TLS endpoint, which may be a reverse proxy. Setting `useSystemCa = true` in `frps`'s `transport.tls` or either process's `webServer.tls` or `metricsServer.tls` is a configuration error.
|
||||||
|
|
||||||
**`frps` TLS settings:**
|
**`frps` TLS settings:**
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -805,6 +827,15 @@ webServer.port = 7400
|
|||||||
|
|
||||||
Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies.
|
Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies.
|
||||||
|
|
||||||
|
For unchanged proxies using `https2http` or `https2https`, `frpc reload` also rereads
|
||||||
|
certificate and key files so externally renewed certificates take effect on new
|
||||||
|
TLS handshakes without interrupting existing connections. Unchanged file contents
|
||||||
|
leave the certificate untouched. If either file cannot be read or the pair is
|
||||||
|
invalid, the proxy keeps its previous certificate and reports the error in its
|
||||||
|
status and logs; reload still succeeds. A successful retry clears the error.
|
||||||
|
Generated self-signed certificates are unchanged, and Store API updates do not
|
||||||
|
refresh certificates on unchanged proxies.
|
||||||
|
|
||||||
**Note that global client parameters won't be modified except 'start'.**
|
**Note that global client parameters won't be modified except 'start'.**
|
||||||
|
|
||||||
`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
|
`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
|
||||||
|
|||||||
+10
-10
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
[](https://circleci.com/gh/fatedier/frp)
|
[](https://circleci.com/gh/fatedier/frp)
|
||||||
[](https://github.com/fatedier/frp/releases)
|
[](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)
|
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||||
|
|
||||||
[README](README.md) | [中文文档](README_zh.md)
|
[README](README.md) | [中文文档](README_zh.md)
|
||||||
@@ -15,6 +14,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Recall.ai - API for meeting recordings
|
||||||
|
|
||||||
|
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||||
|
|
||||||
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -33,15 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
|
||||||
|
|
||||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
|
||||||
|
|
||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
+7
-5
@@ -1,9 +1,11 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
|
* frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`.
|
||||||
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
* `frpc reload` now refreshes certificate files for unchanged `https2http` and `https2https` proxies without interrupting existing connections. Failed refreshes keep the previous certificate and report the error in proxy status and logs while reload still succeeds.
|
||||||
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
* Added `metricsServer` to both frps and frpc, a separate listener for health checks, metrics and optional profiling that does not expose the dashboard or the admin API. It is disabled unless `metricsServer.port` is set, defaults to `127.0.0.1`, and has its own credentials and TLS. Setting the port serves `/metrics` on both: frps exposes runtime and business metrics, so `enablePrometheus` is not needed there, and frpc exposes Go runtime metrics only, without linking an extra metrics library.
|
||||||
|
|
||||||
## Compatibility Notes
|
The `/healthz`, `/metrics` and pprof endpoints on `webServer`, `webServer.pprofEnable` and `enablePrometheus` still work but are deprecated: configure `metricsServer` instead. They will continue to work until v0.80.0 is released, and may be removed in v0.80.0 or later.
|
||||||
|
|
||||||
* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
|
## Fixes
|
||||||
|
|
||||||
|
* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection.
|
||||||
|
|||||||
@@ -45,10 +45,29 @@ func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
|
|||||||
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
|
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
|
var reloadPlugins func()
|
||||||
|
err = func() error {
|
||||||
|
m.svr.reloadMu.Lock()
|
||||||
|
defer m.svr.reloadMu.Unlock()
|
||||||
|
|
||||||
|
// Snapshot plugins and apply config in the same transaction as control handoff.
|
||||||
|
m.svr.ctlMu.RLock()
|
||||||
|
ctl := m.svr.ctl
|
||||||
|
m.svr.ctlMu.RUnlock()
|
||||||
|
if ctl != nil {
|
||||||
|
reloadPlugins = ctl.pm.PreparePluginReload()
|
||||||
|
}
|
||||||
|
return m.svr.updateConfigSourceLocked(result.Common, result.Proxies, result.Visitors)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
|
return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Certificate file I/O must not hold the reload transaction lock.
|
||||||
|
if reloadPlugins != nil {
|
||||||
|
reloadPlugins()
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("success reload conf")
|
log.Infof("success reload conf")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/client/proxy"
|
||||||
|
"github.com/fatedier/frp/client/visitor"
|
||||||
|
"github.com/fatedier/frp/pkg/config"
|
||||||
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
type certificateReloadSender struct{ started chan string }
|
||||||
|
|
||||||
|
func (s *certificateReloadSender) Send(m msg.Message) error {
|
||||||
|
if start, ok := m.(*msg.NewProxy); ok {
|
||||||
|
s.started <- start.ProxyName
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReloadCertificate(t *testing.T, crtPath, keyPath string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := transport.NewServerTLSConfig("", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cert := cfg.Certificates[0]
|
||||||
|
key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(crtPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
|
||||||
|
return cert.Certificate[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileReloadCertificatesAndStoreMutations(t *testing.T) {
|
||||||
|
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
|
||||||
|
t.Run(pluginType, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
|
||||||
|
original := writeReloadCertificate(t, crtPath, keyPath)
|
||||||
|
configPath := filepath.Join(dir, "frpc.toml")
|
||||||
|
content := fmt.Sprintf(`[[proxies]]
|
||||||
|
name = "https"
|
||||||
|
type = "tcp"
|
||||||
|
remotePort = 8443
|
||||||
|
[proxies.plugin]
|
||||||
|
type = %q
|
||||||
|
localAddr = "127.0.0.1:80"
|
||||||
|
crtPath = %q
|
||||||
|
keyPath = %q
|
||||||
|
`, pluginType, crtPath, keyPath)
|
||||||
|
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600))
|
||||||
|
result, err := config.LoadClientConfigResult(configPath, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
configSource := source.NewConfigSource()
|
||||||
|
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{Path: filepath.Join(dir, "store.json")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
aggregator := source.NewAggregator(configSource)
|
||||||
|
aggregator.SetStoreSource(storeSource)
|
||||||
|
sender := &certificateReloadSender{started: make(chan string, 4)}
|
||||||
|
transporter := transport.NewMessageTransporter(sender)
|
||||||
|
ctx := context.Background()
|
||||||
|
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
|
||||||
|
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
|
||||||
|
t.Cleanup(pm.Close)
|
||||||
|
t.Cleanup(vm.Close)
|
||||||
|
svr := &Service{
|
||||||
|
common: result.Common,
|
||||||
|
configFilePath: configPath,
|
||||||
|
unsafeFeatures: security.NewUnsafeFeatures(nil),
|
||||||
|
aggregator: aggregator,
|
||||||
|
configSource: configSource,
|
||||||
|
storeSource: storeSource,
|
||||||
|
ctl: &Control{pm: pm, vm: vm},
|
||||||
|
}
|
||||||
|
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
|
||||||
|
select {
|
||||||
|
case name := <-sender.started:
|
||||||
|
require.Equal(t, "https", name)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("proxy did not request startup")
|
||||||
|
}
|
||||||
|
require.NoError(t, pm.StartProxy("https", ":8443", ""))
|
||||||
|
manager := &serviceConfigManager{svr: svr}
|
||||||
|
|
||||||
|
connect := func(wantCert []byte) *tls.Conn {
|
||||||
|
t.Helper()
|
||||||
|
clientRaw, serverRaw := net.Pipe()
|
||||||
|
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
|
||||||
|
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
|
||||||
|
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
|
||||||
|
require.NoError(t, conn.Handshake())
|
||||||
|
require.Equal(t, wantCert, conn.ConnectionState().PeerCertificates[0].Raw)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
oldConn := connect(original)
|
||||||
|
reader := bufio.NewReader(oldConn)
|
||||||
|
checkExistingConnection := func() {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, oldConn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
// A mismatched Host is handled locally, so no upstream network is needed.
|
||||||
|
_, err := io.WriteString(oldConn, "GET / HTTP/1.1\r\nHost: other.example\r\n\r\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode)
|
||||||
|
_, err = io.Copy(io.Discard, resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
}
|
||||||
|
checkExistingConnection()
|
||||||
|
require.NoError(t, manager.ReloadFromFile(true))
|
||||||
|
require.NoError(t, connect(original).NetConn().Close())
|
||||||
|
|
||||||
|
next := writeReloadCertificate(t, crtPath, keyPath)
|
||||||
|
// Both store mutation helpers must leave unchanged proxies' certificates alone.
|
||||||
|
_, err = manager.CreateStoreProxy(result.Proxies[0].Clone())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, connect(original).NetConn().Close())
|
||||||
|
_, err = manager.UpdateStoreProxy("https", result.Proxies[0].Clone())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, connect(original).NetConn().Close())
|
||||||
|
require.NoError(t, manager.DeleteStoreProxy("https"))
|
||||||
|
require.NoError(t, connect(original).NetConn().Close())
|
||||||
|
require.NoError(t, manager.ReloadFromFile(true))
|
||||||
|
require.NoError(t, connect(next).NetConn().Close())
|
||||||
|
checkExistingConnection()
|
||||||
|
|
||||||
|
for _, badFile := range []string{"missing", "invalid"} {
|
||||||
|
saved, err := os.ReadFile(crtPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if badFile == "missing" {
|
||||||
|
require.NoError(t, os.Remove(crtPath))
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
|
||||||
|
}
|
||||||
|
require.NoError(t, manager.ReloadFromFile(true), "certificate errors must not fail reload")
|
||||||
|
status, ok := pm.GetProxyStatus("https")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, proxy.ProxyPhaseRunning, status.Phase)
|
||||||
|
require.Contains(t, status.Err, "reload plugin ")
|
||||||
|
require.NoError(t, connect(next).NetConn().Close())
|
||||||
|
checkExistingConnection()
|
||||||
|
require.NoError(t, os.WriteFile(crtPath, saved, 0o600))
|
||||||
|
require.NoError(t, manager.ReloadFromFile(true))
|
||||||
|
status, _ = pm.GetProxyStatus("https")
|
||||||
|
require.Empty(t, status.Err, "restoring the active bytes must clear the reload error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config change still replaces the proxy and uses normal startup errors.
|
||||||
|
require.NoError(t, oldConn.NetConn().Close())
|
||||||
|
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
|
||||||
|
changedConfig := strings.Replace(content, "remotePort = 8443", "remotePort = 8444", 1)
|
||||||
|
require.NoError(t, os.WriteFile(configPath, []byte(changedConfig), 0o600))
|
||||||
|
require.NoError(t, manager.ReloadFromFile(true))
|
||||||
|
select {
|
||||||
|
case name := <-sender.started:
|
||||||
|
require.Equal(t, "https", name)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("replacement proxy did not request startup")
|
||||||
|
}
|
||||||
|
require.Error(t, pm.StartProxy("https", ":8444", ""))
|
||||||
|
status, ok := pm.GetProxyStatus("https")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, proxy.ProxyPhaseStartErr, status.Phase)
|
||||||
|
require.NotEmpty(t, status.Err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileReloadCertificatesDuringControlHandoff(t *testing.T) {
|
||||||
|
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
|
||||||
|
t.Run(pluginType, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
|
||||||
|
original := writeReloadCertificate(t, crtPath, keyPath)
|
||||||
|
configPath := filepath.Join(dir, "frpc.toml")
|
||||||
|
require.NoError(t, os.WriteFile(configPath, fmt.Appendf(nil, `[[proxies]]
|
||||||
|
name = "https"
|
||||||
|
type = "tcp"
|
||||||
|
remotePort = 8443
|
||||||
|
[proxies.plugin]
|
||||||
|
type = %q
|
||||||
|
localAddr = "127.0.0.1:80"
|
||||||
|
crtPath = %q
|
||||||
|
keyPath = %q
|
||||||
|
`, pluginType, crtPath, keyPath), 0o600))
|
||||||
|
result, err := config.LoadClientConfigResult(configPath, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
configSource := source.NewConfigSource()
|
||||||
|
svr := &Service{
|
||||||
|
common: result.Common,
|
||||||
|
configFilePath: configPath,
|
||||||
|
unsafeFeatures: security.NewUnsafeFeatures(nil),
|
||||||
|
aggregator: source.NewAggregator(configSource),
|
||||||
|
configSource: configSource,
|
||||||
|
}
|
||||||
|
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
|
||||||
|
sender := &certificateReloadSender{started: make(chan string, 1)}
|
||||||
|
transporter := transport.NewMessageTransporter(sender)
|
||||||
|
newControl := func() *Control {
|
||||||
|
ctx := context.Background()
|
||||||
|
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
|
||||||
|
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
|
||||||
|
t.Cleanup(pm.Close)
|
||||||
|
t.Cleanup(vm.Close)
|
||||||
|
return &Control{pm: pm, vm: vm}
|
||||||
|
}
|
||||||
|
previous := newControl()
|
||||||
|
previous.pm.Close()
|
||||||
|
svr.ctl = previous
|
||||||
|
next := newControl()
|
||||||
|
|
||||||
|
// Model the handoff transaction: start the new control's proxies while
|
||||||
|
// the published control is still the closed, empty previous manager.
|
||||||
|
svr.reloadMu.Lock()
|
||||||
|
releaseTransaction := sync.OnceFunc(svr.reloadMu.Unlock)
|
||||||
|
defer releaseTransaction()
|
||||||
|
next.pm.UpdateAll(svr.proxyCfgs)
|
||||||
|
select {
|
||||||
|
case name := <-sender.started:
|
||||||
|
require.Equal(t, "https", name)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("replacement proxy did not request startup")
|
||||||
|
}
|
||||||
|
require.NoError(t, next.pm.StartProxy("https", ":8443", ""))
|
||||||
|
checkCertificate := func(want []byte) {
|
||||||
|
t.Helper()
|
||||||
|
clientRaw, serverRaw := net.Pipe()
|
||||||
|
defer clientRaw.Close()
|
||||||
|
defer serverRaw.Close()
|
||||||
|
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
next.pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
|
||||||
|
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
|
||||||
|
require.NoError(t, conn.Handshake())
|
||||||
|
require.Equal(t, want, conn.ConnectionState().PeerCertificates[0].Raw)
|
||||||
|
}
|
||||||
|
checkCertificate(original)
|
||||||
|
renewed := writeReloadCertificate(t, crtPath, keyPath)
|
||||||
|
|
||||||
|
svr.ctlMu.Lock()
|
||||||
|
releaseControl := sync.OnceFunc(svr.ctlMu.Unlock)
|
||||||
|
defer releaseControl()
|
||||||
|
reloadStarted, reloadDone := make(chan struct{}), make(chan struct{})
|
||||||
|
var reloadErr error
|
||||||
|
go func() {
|
||||||
|
close(reloadStarted)
|
||||||
|
reloadErr = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
|
||||||
|
close(reloadDone)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
releaseTransaction()
|
||||||
|
releaseControl()
|
||||||
|
waitLifecycleSignal(t, reloadDone)
|
||||||
|
})
|
||||||
|
waitLifecycleSignal(t, reloadStarted)
|
||||||
|
svr.ctl = next
|
||||||
|
releaseTransaction()
|
||||||
|
|
||||||
|
// Keep ctlMu as a read barrier after publication. Reload must acquire
|
||||||
|
// reloadMu before selecting ctl, independently of goroutine scheduling.
|
||||||
|
// The deadline only detects a broken lock order; it does not stage the race.
|
||||||
|
deadline := time.NewTimer(5 * time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
for svr.reloadMu.TryLock() {
|
||||||
|
svr.reloadMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-deadline.C:
|
||||||
|
t.Fatal("reload selected ctl without first acquiring reloadMu")
|
||||||
|
default:
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
releaseControl()
|
||||||
|
waitLifecycleSignal(t, reloadDone)
|
||||||
|
require.NoError(t, reloadErr)
|
||||||
|
checkCertificate(renewed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/fatedier/frp/client/configmgmt"
|
"github.com/fatedier/frp/client/configmgmt"
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
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 {
|
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) {
|
func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
|
||||||
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
|
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
|
||||||
Path: filepath.Join(t.TempDir(), "store.json"),
|
Path: filepath.Join(t.TempDir(), "store.json"),
|
||||||
|
|||||||
+7
-5
@@ -24,7 +24,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
libnet "github.com/fatedier/golib/net"
|
libnet "github.com/fatedier/golib/net"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@@ -109,11 +109,12 @@ func (c *defaultConnectorImpl) Open() error {
|
|||||||
sn = c.cfg.ServerAddr
|
sn = c.cfg.ServerAddr
|
||||||
}
|
}
|
||||||
if lo.FromPtr(c.cfg.Transport.TLS.Enable) {
|
if lo.FromPtr(c.cfg.Transport.TLS.Enable) {
|
||||||
tlsConfig, err = transport.NewClientTLSConfig(
|
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
|
||||||
c.cfg.Transport.TLS.CertFile,
|
c.cfg.Transport.TLS.CertFile,
|
||||||
c.cfg.Transport.TLS.KeyFile,
|
c.cfg.Transport.TLS.KeyFile,
|
||||||
c.cfg.Transport.TLS.TrustedCaFile,
|
c.cfg.Transport.TLS.TrustedCaFile,
|
||||||
sn)
|
sn,
|
||||||
|
c.cfg.Transport.TLS.UseSystemCa)
|
||||||
} else {
|
} else {
|
||||||
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
|
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
|
||||||
}
|
}
|
||||||
@@ -194,11 +195,12 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
|
|||||||
sn = c.cfg.ServerAddr
|
sn = c.cfg.ServerAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig, err = transport.NewClientTLSConfig(
|
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
|
||||||
c.cfg.Transport.TLS.CertFile,
|
c.cfg.Transport.TLS.CertFile,
|
||||||
c.cfg.Transport.TLS.KeyFile,
|
c.cfg.Transport.TLS.KeyFile,
|
||||||
c.cfg.Transport.TLS.TrustedCaFile,
|
c.cfg.Transport.TLS.TrustedCaFile,
|
||||||
sn)
|
sn,
|
||||||
|
c.cfg.Transport.TLS.UseSystemCa)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("fail to build tls configuration, err: %v", err)
|
xl.Warnf("fail to build tls configuration, err: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+13
-41
@@ -16,9 +16,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,6 +47,8 @@ type SessionContext struct {
|
|||||||
Connector MessageConnector
|
Connector MessageConnector
|
||||||
// Virtual net controller
|
// Virtual net controller
|
||||||
VnetController *vnet.Controller
|
VnetController *vnet.Controller
|
||||||
|
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
|
||||||
|
UDPPacketCodec string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Control struct {
|
type Control struct {
|
||||||
@@ -94,9 +94,16 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
|||||||
ctl.registerMsgHandlers()
|
ctl.registerMsgHandlers()
|
||||||
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
|
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.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
|
return ctl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,44 +169,9 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
|
|||||||
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
||||||
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
|
xl.Warnf("[%s] start error: %v", proxyName, err)
|
||||||
} else {
|
} else {
|
||||||
xl.Infof("[%s] 成功启动隧道", proxyName)
|
xl.Infof("[%s] start proxy success", 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,
|
Auth: d.auth,
|
||||||
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
|
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
|
||||||
VnetController: d.vnetController,
|
VnetController: d.vnetController,
|
||||||
|
UDPPacketCodec: loginResult.udpPacketCodec,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
|
|||||||
type loginExchangeResult struct {
|
type loginExchangeResult struct {
|
||||||
resp *msg.LoginResp
|
resp *msg.LoginResp
|
||||||
crypto *wire.CryptoContext
|
crypto *wire.CryptoContext
|
||||||
|
udpPacketCodec string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
|
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 cryptoContext *wire.CryptoContext
|
||||||
|
var udpPacketCodec string
|
||||||
if wireConn != nil {
|
if wireConn != nil {
|
||||||
serverHelloFrame, err := wireConn.ReadFrame()
|
serverHelloFrame, err := wireConn.ReadFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginRespMsg msg.LoginResp
|
var loginRespMsg msg.LoginResp
|
||||||
@@ -200,6 +204,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
|||||||
return &loginExchangeResult{
|
return &loginExchangeResult{
|
||||||
resp: &loginRespMsg,
|
resp: &loginRespMsg,
|
||||||
crypto: cryptoContext,
|
crypto: cryptoContext,
|
||||||
|
udpPacketCodec: udpPacketCodec,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) {
|
|||||||
defer sessionCtx.Connector.Close()
|
defer sessionCtx.Connector.Close()
|
||||||
|
|
||||||
require.Equal(t, "run-v1", sessionCtx.RunID)
|
require.Equal(t, "run-v1", sessionCtx.RunID)
|
||||||
|
require.Empty(t, sessionCtx.UDPPacketCodec)
|
||||||
require.NotNil(t, sessionCtx.Conn)
|
require.NotNil(t, sessionCtx.Conn)
|
||||||
require.NotNil(t, sessionCtx.Connector)
|
require.NotNil(t, sessionCtx.Connector)
|
||||||
require.False(t, connector.closed.Load())
|
require.False(t, connector.closed.Load())
|
||||||
@@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
|
|||||||
defer sessionCtx.Connector.Close()
|
defer sessionCtx.Connector.Close()
|
||||||
|
|
||||||
require.Equal(t, "run-v2", sessionCtx.RunID)
|
require.Equal(t, "run-v2", sessionCtx.RunID)
|
||||||
|
require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec)
|
||||||
require.NotNil(t, sessionCtx.Conn)
|
require.NotNil(t, sessionCtx.Conn)
|
||||||
require.NotNil(t, sessionCtx.Connector)
|
require.NotNil(t, sessionCtx.Connector)
|
||||||
require.False(t, connector.closed.Load())
|
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)
|
||||||
|
}
|
||||||
+52
-15
@@ -30,6 +30,11 @@ import (
|
|||||||
|
|
||||||
var ErrHealthCheckType = errors.New("error health check type")
|
var ErrHealthCheckType = errors.New("error health check type")
|
||||||
|
|
||||||
|
func newHealthTimer(interval time.Duration) (<-chan time.Time, func()) {
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
return timer.C, func() { timer.Stop() }
|
||||||
|
}
|
||||||
|
|
||||||
type Monitor struct {
|
type Monitor struct {
|
||||||
checkType string
|
checkType string
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
@@ -49,6 +54,9 @@ type Monitor struct {
|
|||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
doneCh chan struct{}
|
||||||
|
|
||||||
|
timerFactory func(time.Duration) (<-chan time.Time, func())
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
|
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
|
||||||
@@ -91,6 +99,8 @@ func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
|
|||||||
statusFailedFn: statusFailedFn,
|
statusFailedFn: statusFailedFn,
|
||||||
ctx: newctx,
|
ctx: newctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
timerFactory: newHealthTimer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,29 +112,48 @@ func (monitor *Monitor) Stop() {
|
|||||||
monitor.cancel()
|
monitor.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (monitor *Monitor) checkWorker() {
|
// Done is closed when the worker launched by Start has exited.
|
||||||
xl := xlog.FromContextSafe(monitor.ctx)
|
func (monitor *Monitor) Done() <-chan struct{} {
|
||||||
for {
|
return monitor.doneCh
|
||||||
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
|
|
||||||
err := monitor.doCheck(doCtx)
|
|
||||||
|
|
||||||
// check if this monitor has been closed
|
|
||||||
select {
|
|
||||||
case <-monitor.ctx.Done():
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (monitor *Monitor) checkWorker() {
|
||||||
|
defer close(monitor.doneCh)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if monitor.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
|
||||||
|
err := monitor.doCheck(doCtx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// check if this monitor has been closed
|
||||||
|
if monitor.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
monitor.handleCheckResult(err)
|
||||||
|
|
||||||
|
if !monitor.waitForNextCheck() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (monitor *Monitor) handleCheckResult(err error) {
|
||||||
|
xl := xlog.FromContextSafe(monitor.ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
xl.Tracef("do one health check success")
|
xl.Tracef("do one health check success")
|
||||||
|
monitor.failedTimes = 0
|
||||||
if !monitor.statusOK && monitor.statusNormalFn != nil {
|
if !monitor.statusOK && monitor.statusNormalFn != nil {
|
||||||
xl.Infof("health check status change to success")
|
xl.Infof("health check status change to success")
|
||||||
monitor.statusOK = true
|
monitor.statusOK = true
|
||||||
monitor.statusNormalFn()
|
monitor.statusNormalFn()
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
xl.Warnf("do one health check failed: %v", err)
|
xl.Warnf("do one health check failed: %v", err)
|
||||||
monitor.failedTimes++
|
monitor.failedTimes++
|
||||||
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
|
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
|
||||||
@@ -134,7 +163,15 @@ func (monitor *Monitor) checkWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(monitor.interval)
|
func (monitor *Monitor) waitForNextCheck() bool {
|
||||||
|
timerC, stopTimer := monitor.timerFactory(monitor.interval)
|
||||||
|
defer stopTimer()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-monitor.ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-timerC:
|
||||||
|
return monitor.ctx.Err() == nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tcpHealthBackend struct {
|
||||||
|
listener net.Listener
|
||||||
|
accepted chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTCPHealthBackend(t *testing.T, addr string, accepted chan struct{}) *tcpHealthBackend {
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
backend := &tcpHealthBackend{
|
||||||
|
listener: listener,
|
||||||
|
accepted: accepted,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer close(backend.done)
|
||||||
|
for {
|
||||||
|
conn, err := backend.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
select {
|
||||||
|
case backend.accepted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (backend *tcpHealthBackend) Close() {
|
||||||
|
_ = backend.listener.Close()
|
||||||
|
<-backend.done
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorConsecutiveFailureWindows(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(*testing.T, func(), func()) (*Monitor, func(bool))
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "HTTP",
|
||||||
|
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
|
||||||
|
var healthy atomic.Bool
|
||||||
|
healthy.Store(true)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if !healthy.Load() {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{
|
||||||
|
Type: "http",
|
||||||
|
Path: "/health",
|
||||||
|
TimeoutSeconds: 1,
|
||||||
|
IntervalSeconds: 1,
|
||||||
|
MaxFailed: 3,
|
||||||
|
},
|
||||||
|
strings.TrimPrefix(server.URL, "http://"),
|
||||||
|
normalFn,
|
||||||
|
failedFn,
|
||||||
|
)
|
||||||
|
return monitor, healthy.Store
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TCP",
|
||||||
|
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{
|
||||||
|
Type: "tcp",
|
||||||
|
TimeoutSeconds: 1,
|
||||||
|
IntervalSeconds: 1,
|
||||||
|
MaxFailed: 3,
|
||||||
|
},
|
||||||
|
listener.Addr().String(),
|
||||||
|
normalFn,
|
||||||
|
failedFn,
|
||||||
|
)
|
||||||
|
healthyAddr := monitor.addr
|
||||||
|
return monitor, func(healthy bool) {
|
||||||
|
if healthy {
|
||||||
|
monitor.addr = healthyAddr
|
||||||
|
} else {
|
||||||
|
monitor.addr = "127.0.0.1:0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var events []string
|
||||||
|
monitor, setHealthy := test.setup(
|
||||||
|
t,
|
||||||
|
func() { events = append(events, "normal") },
|
||||||
|
func() { events = append(events, "failed") },
|
||||||
|
)
|
||||||
|
t.Cleanup(monitor.Stop)
|
||||||
|
|
||||||
|
runCheck := func(healthy bool) {
|
||||||
|
t.Helper()
|
||||||
|
setHealthy(healthy)
|
||||||
|
ctx, cancel := context.WithTimeout(monitor.ctx, time.Second)
|
||||||
|
err := monitor.doCheck(ctx)
|
||||||
|
cancel()
|
||||||
|
if healthy {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
monitor.handleCheckResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCheck(true)
|
||||||
|
require.True(t, monitor.statusOK)
|
||||||
|
require.Zero(t, monitor.failedTimes)
|
||||||
|
require.Equal(t, []string{"normal"}, events)
|
||||||
|
|
||||||
|
runCheck(false)
|
||||||
|
runCheck(false)
|
||||||
|
require.True(t, monitor.statusOK)
|
||||||
|
require.Equal(t, uint64(2), monitor.failedTimes)
|
||||||
|
require.Equal(t, []string{"normal"}, events)
|
||||||
|
|
||||||
|
runCheck(true)
|
||||||
|
require.True(t, monitor.statusOK)
|
||||||
|
require.Zero(t, monitor.failedTimes)
|
||||||
|
require.Equal(t, []string{"normal"}, events)
|
||||||
|
|
||||||
|
runCheck(false)
|
||||||
|
require.True(t, monitor.statusOK)
|
||||||
|
require.Equal(t, uint64(1), monitor.failedTimes)
|
||||||
|
require.Equal(t, []string{"normal"}, events)
|
||||||
|
|
||||||
|
runCheck(false)
|
||||||
|
require.True(t, monitor.statusOK)
|
||||||
|
require.Equal(t, uint64(2), monitor.failedTimes)
|
||||||
|
|
||||||
|
runCheck(false)
|
||||||
|
require.False(t, monitor.statusOK)
|
||||||
|
require.Equal(t, uint64(3), monitor.failedTimes)
|
||||||
|
require.Equal(t, []string{"normal", "failed"}, events)
|
||||||
|
|
||||||
|
runCheck(true)
|
||||||
|
require.True(t, monitor.statusOK)
|
||||||
|
require.Zero(t, monitor.failedTimes)
|
||||||
|
require.Equal(t, []string{"normal", "failed", "normal"}, events)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type workerStatus struct {
|
||||||
|
failedTimes uint64
|
||||||
|
statusOK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type workerInterval struct {
|
||||||
|
status workerStatus
|
||||||
|
timer chan time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeWorkerIntervals(monitor *Monitor) <-chan workerInterval {
|
||||||
|
intervals := make(chan workerInterval, 1)
|
||||||
|
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
|
||||||
|
timer := make(chan time.Time, 1)
|
||||||
|
// The worker takes this snapshot after handleCheckResult. Sending it
|
||||||
|
// publishes the state to the test; no test goroutine reads live fields.
|
||||||
|
interval := workerInterval{
|
||||||
|
status: workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK},
|
||||||
|
timer: timer,
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case intervals <- interval:
|
||||||
|
case <-monitor.ctx.Done():
|
||||||
|
}
|
||||||
|
return timer, func() {}
|
||||||
|
}
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitWorkerInterval(t *testing.T, intervals <-chan workerInterval, want workerStatus) chan time.Time {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case interval := <-intervals:
|
||||||
|
require.Equal(t, want, interval.status)
|
||||||
|
return interval.timer
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("health worker did not reach the interval barrier")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMonitorWorker(t *testing.T, monitor *Monitor) {
|
||||||
|
t.Helper()
|
||||||
|
monitor.Stop()
|
||||||
|
select {
|
||||||
|
case <-monitor.Done():
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Error("health worker did not exit after Stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorWorkerProcessesResults(t *testing.T) {
|
||||||
|
requestReady := make(chan struct{}, 1)
|
||||||
|
responses := make(chan int)
|
||||||
|
requestCanceled := make(chan struct{})
|
||||||
|
var cancelOnce sync.Once
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case requestReady <- struct{}{}:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case code := <-responses:
|
||||||
|
w.WriteHeader(code)
|
||||||
|
case <-r.Context().Done():
|
||||||
|
cancelOnce.Do(func() { close(requestCanceled) })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
events := make(chan string, 3)
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{
|
||||||
|
Type: "http",
|
||||||
|
Path: "/health",
|
||||||
|
TimeoutSeconds: 5,
|
||||||
|
MaxFailed: 3,
|
||||||
|
},
|
||||||
|
strings.TrimPrefix(server.URL, "http://"),
|
||||||
|
func() { events <- "normal" },
|
||||||
|
func() { events <- "failed" },
|
||||||
|
)
|
||||||
|
intervals := observeWorkerIntervals(monitor)
|
||||||
|
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
|
||||||
|
|
||||||
|
awaitRequest := func() {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-requestReady:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("health check request did not start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
awaitEvent := func(want string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case got := <-events:
|
||||||
|
require.Equal(t, want, got)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("health check callback %q was not called", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkResult := func(code int, want workerStatus, event string) {
|
||||||
|
t.Helper()
|
||||||
|
awaitRequest()
|
||||||
|
select {
|
||||||
|
case responses <- code:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("health check handler did not accept the response")
|
||||||
|
}
|
||||||
|
timer := awaitWorkerInterval(t, intervals, want)
|
||||||
|
if event != "" {
|
||||||
|
awaitEvent(event)
|
||||||
|
}
|
||||||
|
// The worker has finished this result and cannot emit another
|
||||||
|
// callback until the test releases the next check.
|
||||||
|
select {
|
||||||
|
case got := <-events:
|
||||||
|
t.Fatalf("unexpected health check callback %q", got)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
timer <- time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.Start()
|
||||||
|
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
|
||||||
|
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
|
||||||
|
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
|
||||||
|
checkResult(http.StatusOK, workerStatus{statusOK: true}, "")
|
||||||
|
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
|
||||||
|
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
|
||||||
|
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 3}, "failed")
|
||||||
|
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
|
||||||
|
|
||||||
|
awaitRequest()
|
||||||
|
stopMonitorWorker(t, monitor)
|
||||||
|
select {
|
||||||
|
case <-requestCanceled:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("health check request context was not canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorTCPWorkerProcessesResults(t *testing.T) {
|
||||||
|
initialAccepted := make(chan struct{}, 1)
|
||||||
|
initialBackend := newTCPHealthBackend(t, "127.0.0.1:0", initialAccepted)
|
||||||
|
addr := initialBackend.listener.Addr().String()
|
||||||
|
recoveryAccepted := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
normalCallbacks := make(chan workerStatus, 2)
|
||||||
|
failedCallbacks := make(chan workerStatus, 2)
|
||||||
|
var monitor *Monitor
|
||||||
|
monitor = NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{
|
||||||
|
Type: "tcp",
|
||||||
|
TimeoutSeconds: 1,
|
||||||
|
MaxFailed: 3,
|
||||||
|
},
|
||||||
|
addr,
|
||||||
|
func() {
|
||||||
|
normalCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
|
||||||
|
},
|
||||||
|
func() {
|
||||||
|
failedCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
intervals := observeWorkerIntervals(monitor)
|
||||||
|
recoveryBackend := (*tcpHealthBackend)(nil)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stopMonitorWorker(t, monitor)
|
||||||
|
initialBackend.Close()
|
||||||
|
if recoveryBackend != nil {
|
||||||
|
recoveryBackend.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
awaitStatus := func(ch <-chan workerStatus, want workerStatus, message string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case got := <-ch:
|
||||||
|
require.Equal(t, want, got)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.Start()
|
||||||
|
select {
|
||||||
|
case <-initialAccepted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("TCP health check did not reach the initial backend")
|
||||||
|
}
|
||||||
|
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report the initial success")
|
||||||
|
|
||||||
|
initialTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
|
||||||
|
initialBackend.Close()
|
||||||
|
initialTimer <- time.Now()
|
||||||
|
|
||||||
|
firstFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
|
||||||
|
firstFailureTimer <- time.Now()
|
||||||
|
|
||||||
|
secondFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
|
||||||
|
secondFailureTimer <- time.Now()
|
||||||
|
|
||||||
|
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check")
|
||||||
|
thirdFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 3})
|
||||||
|
|
||||||
|
recoveryBackend = newTCPHealthBackend(t, addr, recoveryAccepted)
|
||||||
|
thirdFailureTimer <- time.Now()
|
||||||
|
select {
|
||||||
|
case <-recoveryAccepted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("TCP health check did not reach the recovery backend")
|
||||||
|
}
|
||||||
|
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report recovery")
|
||||||
|
|
||||||
|
recoveryTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
|
||||||
|
recoveryBackend.Close()
|
||||||
|
recoveryTimer <- time.Now()
|
||||||
|
|
||||||
|
firstRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
|
||||||
|
firstRecoveryFailureTimer <- time.Now()
|
||||||
|
|
||||||
|
secondRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
|
||||||
|
secondRecoveryFailureTimer <- time.Now()
|
||||||
|
|
||||||
|
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check after recovery")
|
||||||
|
stopMonitorWorker(t, monitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorMaxFailedOne(t *testing.T) {
|
||||||
|
var events []string
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{Type: "tcp", MaxFailed: 1},
|
||||||
|
"",
|
||||||
|
func() { events = append(events, "normal") },
|
||||||
|
func() { events = append(events, "failed") },
|
||||||
|
)
|
||||||
|
t.Cleanup(monitor.Stop)
|
||||||
|
|
||||||
|
checkErr := errors.New("health check failed")
|
||||||
|
monitor.handleCheckResult(nil)
|
||||||
|
monitor.handleCheckResult(checkErr)
|
||||||
|
require.Equal(t, []string{"normal", "failed"}, events)
|
||||||
|
require.False(t, monitor.statusOK)
|
||||||
|
require.Equal(t, uint64(1), monitor.failedTimes)
|
||||||
|
|
||||||
|
monitor.handleCheckResult(checkErr)
|
||||||
|
require.Equal(t, []string{"normal", "failed"}, events)
|
||||||
|
|
||||||
|
monitor.handleCheckResult(nil)
|
||||||
|
monitor.handleCheckResult(checkErr)
|
||||||
|
require.Equal(t, []string{"normal", "failed", "normal", "failed"}, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorStopCancelsWork(t *testing.T) {
|
||||||
|
t.Run("in-flight check", func(t *testing.T) {
|
||||||
|
requestStarted := make(chan struct{})
|
||||||
|
requestCanceled := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
close(requestStarted)
|
||||||
|
<-r.Context().Done()
|
||||||
|
close(requestCanceled)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{Type: "http", Path: "/health"},
|
||||||
|
strings.TrimPrefix(server.URL, "http://"),
|
||||||
|
func() {},
|
||||||
|
func() {},
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
|
||||||
|
monitor.Start()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-requestStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("health check request did not start")
|
||||||
|
}
|
||||||
|
stopMonitorWorker(t, monitor)
|
||||||
|
select {
|
||||||
|
case <-requestCanceled:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("health check request context was not canceled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("interval wait", func(t *testing.T) {
|
||||||
|
monitor := NewMonitor(context.Background(), v1.HealthCheckConfig{Type: "tcp"}, "", nil, nil)
|
||||||
|
intervals := observeWorkerIntervals(monitor)
|
||||||
|
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
|
||||||
|
monitor.Start()
|
||||||
|
|
||||||
|
// A real worker has completed its first check and installed a timer
|
||||||
|
// that will never fire. Stop must release the wait and exit the loop.
|
||||||
|
awaitWorkerInterval(t, intervals, workerStatus{})
|
||||||
|
stopMonitorWorker(t, monitor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatedErrContext struct {
|
||||||
|
context.Context
|
||||||
|
errCalled chan struct{}
|
||||||
|
releaseErr chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *gatedErrContext) Err() error {
|
||||||
|
select {
|
||||||
|
case ctx.errCalled <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
<-ctx.releaseErr
|
||||||
|
return ctx.Context.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorTimerCancellationAfterTimerFires(t *testing.T) {
|
||||||
|
baseCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
ctx := &gatedErrContext{
|
||||||
|
Context: baseCtx,
|
||||||
|
errCalled: make(chan struct{}, 1),
|
||||||
|
releaseErr: make(chan struct{}),
|
||||||
|
}
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{Type: "tcp"},
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
monitor.ctx = ctx
|
||||||
|
monitor.cancel = cancel
|
||||||
|
|
||||||
|
timerReady := make(chan time.Time, 1)
|
||||||
|
timerReady <- time.Now()
|
||||||
|
var timerStopped atomic.Bool
|
||||||
|
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
|
||||||
|
return timerReady, func() { timerStopped.Store(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
waitResult := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
waitResult <- monitor.waitForNextCheck()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ctx.Done is not ready when select runs, so receiving timerReady is the
|
||||||
|
// only possible branch. Err signals after that receive and blocks until the
|
||||||
|
// test cancels the context, deterministically exercising the cancellation
|
||||||
|
// re-check in the timer branch.
|
||||||
|
select {
|
||||||
|
case <-ctx.errCalled:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timer branch did not re-check the monitor context")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
close(ctx.releaseErr)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case shouldContinue := <-waitResult:
|
||||||
|
require.False(t, shouldContinue)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timer wait did not observe context cancellation")
|
||||||
|
}
|
||||||
|
require.True(t, timerStopped.Load())
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
|
|||||||
}
|
}
|
||||||
psr.Plugin = baseCfg.Plugin.Type
|
psr.Plugin = baseCfg.Plugin.Type
|
||||||
|
|
||||||
if status.Err == "" {
|
if status.Err == "" || status.Phase == proxy.ProxyPhaseRunning {
|
||||||
psr.RemoteAddr = status.RemoteAddr
|
psr.RemoteAddr = status.RemoteAddr
|
||||||
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
||||||
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
||||||
|
|||||||
@@ -213,6 +213,35 @@ func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildProxyStatusRespRunningWithErrorKeepsRemoteAddr(t *testing.T) {
|
||||||
|
const certificateErr = "reload plugin https2http: invalid certificate"
|
||||||
|
controller := &Controller{serverAddr: "127.0.0.1", manager: &fakeConfigManager{}}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, phase, err, remoteAddr string
|
||||||
|
}{
|
||||||
|
{"running", proxy.ProxyPhaseRunning, "", "127.0.0.1:43210"},
|
||||||
|
{"running with error", proxy.ProxyPhaseRunning, certificateErr, "127.0.0.1:43210"},
|
||||||
|
{"start error", proxy.ProxyPhaseStartErr, "start failed", ""},
|
||||||
|
{"closed with error", proxy.ProxyPhaseClosed, certificateErr, ""},
|
||||||
|
{"closed without error", proxy.ProxyPhaseClosed, "", "127.0.0.1:43210"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
status := &proxy.WorkingStatus{
|
||||||
|
Name: "https", Type: "tcp", Phase: tc.phase, Err: tc.err,
|
||||||
|
RemoteAddr: ":43210",
|
||||||
|
Cfg: newRawTCPProxyConfig("https"), // RemotePort remains zero (allocated by frps).
|
||||||
|
}
|
||||||
|
resp := controller.buildProxyStatusResp(status)
|
||||||
|
if resp.RemoteAddr != tc.remoteAddr {
|
||||||
|
t.Fatalf("unexpected remote addr: got %q, want %q", resp.RemoteAddr, tc.remoteAddr)
|
||||||
|
}
|
||||||
|
if resp.Err != tc.err || resp.Status != tc.phase {
|
||||||
|
t.Fatalf("status or error changed: %+v", resp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReloadErrorMapping(t *testing.T) {
|
func TestReloadErrorMapping(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewServiceReleasesWebServerOnMetricsServerFailure(t *testing.T) {
|
||||||
|
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer ln.Close()
|
||||||
|
cfg := &v1.ClientCommonConfig{
|
||||||
|
WebServer: v1.WebServerConfig{Port: getFreeTCPPort(t)},
|
||||||
|
MetricsServer: v1.MetricsServerConfig{Port: ln.Addr().(*net.TCPAddr).Port},
|
||||||
|
}
|
||||||
|
if mode == "invalid TLS certificate" {
|
||||||
|
cfg.MetricsServer.TLS = &v1.TLSConfig{
|
||||||
|
CertFile: filepath.Join(t.TempDir(), "missing.crt"), KeyFile: "missing.key",
|
||||||
|
}
|
||||||
|
require.NoError(t, ln.Close())
|
||||||
|
}
|
||||||
|
svr, err := NewService(ServiceOptions{
|
||||||
|
Common: cfg, ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||||
|
})
|
||||||
|
if svr != nil {
|
||||||
|
svr.Close()
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, svr)
|
||||||
|
assertLifecycleAdminPort(t, net.JoinHostPort(cfg.WebServer.Addr, strconv.Itoa(cfg.WebServer.Port)), true)
|
||||||
|
if mode == "invalid TLS certificate" {
|
||||||
|
assertLifecycleAdminPort(t, ln.Addr().String(), true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceMetricsServerLifecycle(t *testing.T) {
|
||||||
|
svr, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
LoginFailExit: new(false), MetricsServer: v1.MetricsServerConfig{Port: getFreeTCPPort(t)},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||||
|
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||||
|
return &failingConnector{err: net.ErrClosed}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, svr.webServer)
|
||||||
|
address := svr.metricsServer.Address()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { _ = svr.Run(context.Background()); close(done) }()
|
||||||
|
defer func() {
|
||||||
|
svr.Close()
|
||||||
|
waitLifecycleSignal(t, done)
|
||||||
|
assertLifecycleAdminPort(t, address, true)
|
||||||
|
}()
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get("http://" + address + "/healthz")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
|
// frpc serves Go runtime metrics only, and no credentials are configured
|
||||||
|
// here, so this endpoint is reachable without authentication.
|
||||||
|
resp, err = client.Get("http://" + address + "/metrics")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Contains(t, string(body), "go_goroutines")
|
||||||
|
require.NotContains(t, string(body), "frp_")
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
plugin "github.com/fatedier/frp/pkg/plugin/client"
|
||||||
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reloadError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type testReloadPlugin struct {
|
||||||
|
calls atomic.Int32
|
||||||
|
state atomic.Pointer[reloadError]
|
||||||
|
reload func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testReloadPlugin) Name() string { return "test" }
|
||||||
|
func (p *testReloadPlugin) Handle(context.Context, *plugin.ConnectionInfo) {}
|
||||||
|
func (p *testReloadPlugin) Close() error { return nil }
|
||||||
|
|
||||||
|
func (p *testReloadPlugin) Reload() error {
|
||||||
|
p.calls.Add(1)
|
||||||
|
err := p.reload()
|
||||||
|
p.state.Store(&reloadError{err: err})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testReloadPlugin) ReloadError() error {
|
||||||
|
if s := p.state.Load(); s != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadFixture(p *testReloadPlugin) (*Manager, *Wrapper, *BaseProxy) {
|
||||||
|
base := &BaseProxy{proxyPlugin: p}
|
||||||
|
pw := &Wrapper{
|
||||||
|
WorkingStatus: WorkingStatus{Name: "same", Phase: ProxyPhaseRunning},
|
||||||
|
pxy: &GeneralTCPProxy{BaseProxy: base},
|
||||||
|
xl: xlog.New(),
|
||||||
|
}
|
||||||
|
return &Manager{proxies: map[string]*Wrapper{"same": pw}}, pw, base
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginSnapshotSkipsReplacedAndStopped(t *testing.T) {
|
||||||
|
p := &testReloadPlugin{reload: func() error { return nil }}
|
||||||
|
pm, pw, base := reloadFixture(p)
|
||||||
|
run := pm.PreparePluginReload()
|
||||||
|
q := &testReloadPlugin{reload: func() error { return nil }}
|
||||||
|
pw.mu.Lock()
|
||||||
|
base.proxyPlugin = q
|
||||||
|
pw.mu.Unlock()
|
||||||
|
run()
|
||||||
|
require.Zero(t, p.calls.Load())
|
||||||
|
require.Zero(t, q.calls.Load())
|
||||||
|
run = pm.PreparePluginReload()
|
||||||
|
pw.mu.Lock()
|
||||||
|
pw.Phase = ProxyPhaseClosed
|
||||||
|
pw.mu.Unlock()
|
||||||
|
run()
|
||||||
|
require.Zero(t, q.calls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginErrorOwnershipAndLockIsolation(t *testing.T) {
|
||||||
|
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
p := &testReloadPlugin{reload: func() error {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
return errors.New("bad certificate")
|
||||||
|
}}
|
||||||
|
pm, pw, base := reloadFixture(p)
|
||||||
|
other := &testReloadPlugin{reload: func() error { return nil }}
|
||||||
|
pm2, _, _ := reloadFixture(other)
|
||||||
|
run := pm.PreparePluginReload()
|
||||||
|
releaseReload := sync.OnceFunc(func() { close(release) })
|
||||||
|
t.Cleanup(func() {
|
||||||
|
releaseReload()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("reload did not finish")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
run()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("not started")
|
||||||
|
}
|
||||||
|
require.True(t, pm.mu.TryLock())
|
||||||
|
pm.mu.Unlock()
|
||||||
|
require.True(t, pw.mu.TryLock())
|
||||||
|
pw.mu.Unlock()
|
||||||
|
require.Empty(t, pw.GetStatus().Err)
|
||||||
|
require.Zero(t, other.calls.Load(), "same name in another manager must be isolated")
|
||||||
|
pm2.PreparePluginReload()()
|
||||||
|
require.EqualValues(t, 1, other.calls.Load())
|
||||||
|
q := &testReloadPlugin{reload: func() error { return nil }}
|
||||||
|
pw.mu.Lock()
|
||||||
|
base.proxyPlugin = q
|
||||||
|
pw.mu.Unlock()
|
||||||
|
releaseReload()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("not done")
|
||||||
|
}
|
||||||
|
require.Error(t, p.ReloadError())
|
||||||
|
require.Empty(t, pw.GetStatus().Err, "retired plugin errors must not attach to its replacement")
|
||||||
|
q.state.Store(&reloadError{err: errors.New("new plugin error")})
|
||||||
|
require.Contains(t, pw.GetStatus().Err, "new plugin error")
|
||||||
|
require.Empty(t, pw.Err, "plugin state must not overwrite lifecycle errors")
|
||||||
|
pw.mu.Lock()
|
||||||
|
pw.Err = "lifecycle"
|
||||||
|
pw.mu.Unlock()
|
||||||
|
require.Contains(t, pw.GetStatus().Err, "lifecycle; reload plugin")
|
||||||
|
require.NoError(t, q.Reload())
|
||||||
|
require.Equal(t, "lifecycle", pw.GetStatus().Err)
|
||||||
|
}
|
||||||
+26
-43
@@ -20,9 +20,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -63,16 +61,16 @@ func NewProxy(
|
|||||||
encryptionKey []byte,
|
encryptionKey []byte,
|
||||||
msgTransporter transport.MessageTransporter,
|
msgTransporter transport.MessageTransporter,
|
||||||
vnetController *vnet.Controller,
|
vnetController *vnet.Controller,
|
||||||
|
udpPacketCodec string,
|
||||||
) (pxy Proxy) {
|
) (pxy Proxy) {
|
||||||
var limiter *rate.Limiter
|
var limiter *rate.Limiter
|
||||||
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
|
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
|
||||||
if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {
|
if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {
|
||||||
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
|
limiter = limit.NewBandwidthLimiter(limitBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
baseProxy := BaseProxy{
|
baseProxy := BaseProxy{
|
||||||
baseCfg: pxyConf.GetBaseConfig(),
|
baseCfg: pxyConf.GetBaseConfig(),
|
||||||
configurer: pxyConf,
|
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
@@ -80,6 +78,7 @@ func NewProxy(
|
|||||||
vnetController: vnetController,
|
vnetController: vnetController,
|
||||||
xl: xlog.FromContextSafe(ctx),
|
xl: xlog.FromContextSafe(ctx),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
udpPacketCodec: udpPacketCodec,
|
||||||
}
|
}
|
||||||
|
|
||||||
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
|
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
|
||||||
@@ -91,7 +90,6 @@ func NewProxy(
|
|||||||
|
|
||||||
type BaseProxy struct {
|
type BaseProxy struct {
|
||||||
baseCfg *v1.ProxyBaseConfig
|
baseCfg *v1.ProxyBaseConfig
|
||||||
configurer v1.ProxyConfigurer
|
|
||||||
clientCfg *v1.ClientCommonConfig
|
clientCfg *v1.ClientCommonConfig
|
||||||
encryptionKey []byte
|
encryptionKey []byte
|
||||||
msgTransporter transport.MessageTransporter
|
msgTransporter transport.MessageTransporter
|
||||||
@@ -105,13 +103,13 @@ type BaseProxy struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
udpPacketCodec string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *BaseProxy) Run() error {
|
func (pxy *BaseProxy) Run() error {
|
||||||
if pxy.baseCfg.Plugin.Type != "" {
|
if pxy.baseCfg.Plugin.Type != "" {
|
||||||
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
||||||
Name: pxy.baseCfg.Name,
|
Name: pxy.baseCfg.Name,
|
||||||
HostAllowList: pxy.getPluginHostAllowList(),
|
|
||||||
VnetController: pxy.vnetController,
|
VnetController: pxy.vnetController,
|
||||||
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,38 +120,8 @@ func (pxy *BaseProxy) Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *BaseProxy) getPluginHostAllowList() []string {
|
// getPlugin is accessed while the owning Wrapper holds its lifecycle lock.
|
||||||
dedupHosts := make([]string, 0)
|
func (pxy *BaseProxy) getPlugin() plugin.Plugin { return pxy.proxyPlugin }
|
||||||
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() {
|
func (pxy *BaseProxy) Close() {
|
||||||
if pxy.proxyPlugin != nil {
|
if pxy.proxyPlugin != nil {
|
||||||
@@ -209,6 +177,26 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
|||||||
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
|
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
|
||||||
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
|
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)
|
remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Errorf("wrap work connection: %v", err)
|
xl.Errorf("wrap work connection: %v", err)
|
||||||
@@ -218,11 +206,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
|||||||
// check if we need to send proxy protocol info
|
// check if we need to send proxy protocol info
|
||||||
var connInfo plugin.ConnectionInfo
|
var connInfo plugin.ConnectionInfo
|
||||||
if m.SrcAddr != "" && m.SrcPort != 0 {
|
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.SrcAddr = srcAddr
|
||||||
connInfo.DstAddr = dstAddr
|
connInfo.DstAddr = dstAddr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type Manager struct {
|
|||||||
clientCfg *v1.ClientCommonConfig
|
clientCfg *v1.ClientCommonConfig
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
udpPacketCodec string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(
|
func NewManager(
|
||||||
@@ -52,6 +53,7 @@ func NewManager(
|
|||||||
encryptionKey []byte,
|
encryptionKey []byte,
|
||||||
msgTransporter transport.MessageTransporter,
|
msgTransporter transport.MessageTransporter,
|
||||||
vnetController *vnet.Controller,
|
vnetController *vnet.Controller,
|
||||||
|
udpPacketCodec string,
|
||||||
) *Manager {
|
) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
proxies: make(map[string]*Wrapper),
|
proxies: make(map[string]*Wrapper),
|
||||||
@@ -61,6 +63,7 @@ func NewManager(
|
|||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
udpPacketCodec: udpPacketCodec,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +139,41 @@ func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreparePluginReload snapshots live plugin instances before applying config.
|
||||||
|
// Invoke the returned function only after config application succeeds and all
|
||||||
|
// configuration locks have been released. Replaced instances are skipped.
|
||||||
|
func (pm *Manager) PreparePluginReload() func() {
|
||||||
|
type target struct {
|
||||||
|
pw *Wrapper
|
||||||
|
p reloadablePlugin
|
||||||
|
}
|
||||||
|
pm.mu.RLock()
|
||||||
|
targets := make([]target, 0, len(pm.proxies))
|
||||||
|
for _, pw := range pm.proxies {
|
||||||
|
pw.mu.RLock()
|
||||||
|
if pw.Phase == ProxyPhaseRunning {
|
||||||
|
if p := pw.reloadablePluginLocked(); p != nil {
|
||||||
|
targets = append(targets, target{pw: pw, p: p})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.mu.RUnlock()
|
||||||
|
}
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
return func() {
|
||||||
|
for _, t := range targets {
|
||||||
|
t.pw.mu.RLock()
|
||||||
|
current := t.pw.Phase == ProxyPhaseRunning && t.pw.reloadablePluginLocked() == t.p
|
||||||
|
t.pw.mu.RUnlock()
|
||||||
|
if !current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := t.p.Reload(); err != nil {
|
||||||
|
t.pw.xl.Warnf("reload plugin %s: %v", t.p.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||||
xl := xlog.FromContextSafe(pm.ctx)
|
xl := xlog.FromContextSafe(pm.ctx)
|
||||||
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
|
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
|
||||||
@@ -159,14 +197,14 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(delPxyNames) > 0 {
|
if len(delPxyNames) > 0 {
|
||||||
xl.Infof("隧道移除: %s", delPxyNames)
|
xl.Infof("proxy removed: %s", delPxyNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
addPxyNames := make([]string, 0)
|
addPxyNames := make([]string, 0)
|
||||||
for _, cfg := range proxyCfgs {
|
for _, cfg := range proxyCfgs {
|
||||||
name := cfg.GetBaseConfig().Name
|
name := cfg.GetBaseConfig().Name
|
||||||
if _, ok := pm.proxies[name]; !ok {
|
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 {
|
if pm.inWorkConnCallback != nil {
|
||||||
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
|
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
|
||||||
}
|
}
|
||||||
@@ -177,6 +215,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(addPxyNames) > 0 {
|
if len(addPxyNames) > 0 {
|
||||||
xl.Infof("添加隧道: %s", addPxyNames)
|
xl.Infof("proxy added: %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)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/naming"
|
"github.com/fatedier/frp/pkg/naming"
|
||||||
|
plugin "github.com/fatedier/frp/pkg/plugin/client"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
@@ -99,6 +100,7 @@ func NewWrapper(
|
|||||||
eventHandler event.Handler,
|
eventHandler event.Handler,
|
||||||
msgTransporter transport.MessageTransporter,
|
msgTransporter transport.MessageTransporter,
|
||||||
vnetController *vnet.Controller,
|
vnetController *vnet.Controller,
|
||||||
|
udpPacketCodec string,
|
||||||
) *Wrapper {
|
) *Wrapper {
|
||||||
baseInfo := cfg.GetBaseConfig()
|
baseInfo := cfg.GetBaseConfig()
|
||||||
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
|
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
|
||||||
@@ -127,7 +129,7 @@ func NewWrapper(
|
|||||||
xl.Tracef("enable health check monitor")
|
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
|
return pw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,8 +268,9 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
|
|||||||
xl := pw.xl
|
xl := pw.xl
|
||||||
pw.mu.RLock()
|
pw.mu.RLock()
|
||||||
pxy := pw.pxy
|
pxy := pw.pxy
|
||||||
|
running := pw.Phase == ProxyPhaseRunning
|
||||||
pw.mu.RUnlock()
|
pw.mu.RUnlock()
|
||||||
if pxy != nil && pw.Phase == ProxyPhaseRunning {
|
if pxy != nil && running {
|
||||||
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
|
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
|
||||||
go pxy.InWorkConn(workConn, m)
|
go pxy.InWorkConn(workConn, m)
|
||||||
} else {
|
} else {
|
||||||
@@ -275,6 +278,25 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reloadablePlugin owns its reload serialization and error snapshot. ReloadError
|
||||||
|
// must not block on I/O. Reload must be safe during Close, and retain usable
|
||||||
|
// resources on failure. Existing plugins need not implement this capability.
|
||||||
|
type reloadablePlugin interface {
|
||||||
|
plugin.Plugin
|
||||||
|
Reload() error
|
||||||
|
ReloadError() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds pw.mu. Concrete reloadable plugins are pointer instances.
|
||||||
|
func (pw *Wrapper) reloadablePluginLocked() reloadablePlugin {
|
||||||
|
owner, ok := pw.pxy.(interface{ getPlugin() plugin.Plugin })
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, _ := owner.getPlugin().(reloadablePlugin)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func (pw *Wrapper) GetStatus() *WorkingStatus {
|
func (pw *Wrapper) GetStatus() *WorkingStatus {
|
||||||
pw.mu.RLock()
|
pw.mu.RLock()
|
||||||
defer pw.mu.RUnlock()
|
defer pw.mu.RUnlock()
|
||||||
@@ -286,5 +308,15 @@ func (pw *Wrapper) GetStatus() *WorkingStatus {
|
|||||||
Cfg: pw.Cfg,
|
Cfg: pw.Cfg,
|
||||||
RemoteAddr: pw.RemoteAddr,
|
RemoteAddr: pw.RemoteAddr,
|
||||||
}
|
}
|
||||||
|
if pw.Phase == ProxyPhaseRunning {
|
||||||
|
if p := pw.reloadablePluginLocked(); p != nil {
|
||||||
|
if err := p.ReloadError(); err != nil {
|
||||||
|
if ps.Err != "" {
|
||||||
|
ps.Err += "; "
|
||||||
|
}
|
||||||
|
ps.Err += fmt.Sprintf("reload plugin %s: %v", p.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return ps
|
return ps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type closingWorkProxy struct {
|
||||||
|
Proxy
|
||||||
|
accepted atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *closingWorkProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||||
|
p.accepted.Add(1)
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertWrapperWorkConnClosed(t *testing.T, pw *Wrapper) {
|
||||||
|
t.Helper()
|
||||||
|
conn, peer := net.Pipe()
|
||||||
|
defer conn.Close()
|
||||||
|
defer peer.Close()
|
||||||
|
require.NoError(t, peer.SetReadDeadline(time.Now().Add(time.Second)))
|
||||||
|
pw.InWorkConn(conn, &msg.StartWorkConn{})
|
||||||
|
_, err := peer.Read(make([]byte, 1))
|
||||||
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapperInWorkConnPhaseSnapshot(t *testing.T) {
|
||||||
|
pxy := &closingWorkProxy{}
|
||||||
|
pw := &Wrapper{pxy: pxy, xl: xlog.New()}
|
||||||
|
for _, phase := range []string{ProxyPhaseNew, ProxyPhaseWaitStart, ProxyPhaseStartErr, ProxyPhaseCheckFailed, ProxyPhaseClosed} {
|
||||||
|
pw.Phase = phase
|
||||||
|
assertWrapperWorkConnClosed(t, pw)
|
||||||
|
}
|
||||||
|
require.Zero(t, pxy.accepted.Load(), "inactive proxy received a work connection")
|
||||||
|
pw.Phase = ProxyPhaseRunning
|
||||||
|
pw.pxy = nil
|
||||||
|
assertWrapperWorkConnClosed(t, pw)
|
||||||
|
pw.pxy = pxy
|
||||||
|
assertWrapperWorkConnClosed(t, pw)
|
||||||
|
require.Equal(t, int64(1), pxy.accepted.Load())
|
||||||
|
|
||||||
|
start, done := make(chan struct{}), make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
for range 20000 {
|
||||||
|
pw.mu.Lock()
|
||||||
|
pw.Phase = ProxyPhaseRunning
|
||||||
|
pw.mu.Unlock()
|
||||||
|
pw.mu.Lock()
|
||||||
|
pw.Phase = ProxyPhaseClosed
|
||||||
|
pw.mu.Unlock()
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
close(start)
|
||||||
|
for range 10000 {
|
||||||
|
assertWrapperWorkConnClosed(t, pw)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("phase writer did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
|
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
|
||||||
|
if err != nil {
|
||||||
|
xl.Errorf("create SUDP packet read writer: %v", err)
|
||||||
|
_ = workConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payloadConn := msg.NewConn(workConn, payloadRW)
|
||||||
readCh := make(chan *msg.UDPPacket, 1024)
|
readCh := make(chan *msg.UDPPacket, 1024)
|
||||||
sendCh := make(chan msg.Message, 1024)
|
sendCh := make(chan msg.Message, 1024)
|
||||||
isClose := false
|
isClose := false
|
||||||
|
|||||||
+11
-4
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
|
|||||||
|
|
||||||
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
|
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
|
||||||
// close resources related with old workConn
|
// close resources related with old workConn
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
|
|
||||||
@@ -97,10 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pxy.mu.Lock()
|
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
|
||||||
// Plain UDP payload follows the configured wire protocol for message framing.
|
// Plain UDP payload follows the configured wire protocol for message framing.
|
||||||
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
|
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
|
||||||
|
if err != nil {
|
||||||
|
xl.Errorf("create UDP packet read writer: %v", err)
|
||||||
|
workConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pxy.mu.Lock()
|
||||||
|
pxy.workConn = workConn
|
||||||
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
||||||
pxy.sendCh = make(chan msg.Message, 1024)
|
pxy.sendCh = make(chan msg.Message, 1024)
|
||||||
pxy.closed = false
|
pxy.closed = false
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
"github.com/quic-go/quic-go"
|
"github.com/quic-go/quic-go"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
|||||||
+130
-24
@@ -22,6 +22,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
@@ -32,6 +33,8 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/config"
|
"github.com/fatedier/frp/pkg/config"
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/text"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
@@ -109,6 +112,9 @@ func setServiceOptionsDefault(options *ServiceOptions) error {
|
|||||||
// Service is the client service that connects to frps and provides proxy services.
|
// Service is the client service that connects to frps and provides proxy services.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ctlMu sync.RWMutex
|
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
|
// manager control connection with server
|
||||||
ctl *Control
|
ctl *Control
|
||||||
// Uniq id got from frps, it will be attached to loginMsg.
|
// Uniq id got from frps, it will be attached to loginMsg.
|
||||||
@@ -119,12 +125,13 @@ type Service struct {
|
|||||||
|
|
||||||
// web server for admin UI and apis
|
// web server for admin UI and apis
|
||||||
webServer *httppkg.Server
|
webServer *httppkg.Server
|
||||||
|
metricsServer *httppkg.Server
|
||||||
|
|
||||||
vnetController *vnet.Controller
|
vnetController *vnet.Controller
|
||||||
|
|
||||||
cfgMu sync.RWMutex
|
cfgMu sync.RWMutex
|
||||||
// reloadMu serializes reload transactions to keep reloadCommon and applied
|
// reloadMu serializes config updates with control startup and publication,
|
||||||
// config in sync across concurrent API operations.
|
// keeping reloadCommon and applied config in sync across reloads and reconnects.
|
||||||
reloadMu sync.Mutex
|
reloadMu sync.Mutex
|
||||||
common *v1.ClientCommonConfig
|
common *v1.ClientCommonConfig
|
||||||
// reloadCommon is used for filtering/defaulting during config-source reloads.
|
// reloadCommon is used for filtering/defaulting during config-source reloads.
|
||||||
@@ -150,7 +157,9 @@ type Service struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
// call cancel to stop service
|
// call cancel to stop service
|
||||||
cancel context.CancelCauseFunc
|
cancel context.CancelCauseFunc
|
||||||
gracefulShutdownDuration time.Duration
|
// Protect cancel publication and shutdown before the first Run.
|
||||||
|
lifecycleMu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
|
||||||
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
||||||
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
|
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
|
||||||
@@ -181,8 +190,8 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||||
|
|
||||||
// Create the web server after all fallible steps so its listener is not
|
// Create HTTP servers after all other fallible steps, rolling back the web
|
||||||
// leaked when an earlier error causes NewService to return.
|
// listener if the metrics server cannot be created.
|
||||||
var webServer *httppkg.Server
|
var webServer *httppkg.Server
|
||||||
if options.Common.WebServer.Port > 0 {
|
if options.Common.WebServer.Port > 0 {
|
||||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||||
@@ -191,11 +200,27 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
}
|
}
|
||||||
webServer = ws
|
webServer = ws
|
||||||
}
|
}
|
||||||
|
// frpc exposes Go runtime metrics only. They are written by pkg/metrics/text
|
||||||
|
// so that client_golang and protobuf stay out of the frpc binary.
|
||||||
|
var metricsHandler http.Handler
|
||||||
|
if options.Common.MetricsServer.Port > 0 {
|
||||||
|
registry := text.NewRegistry()
|
||||||
|
registry.RegisterRuntime()
|
||||||
|
metricsHandler = registry.Handler()
|
||||||
|
}
|
||||||
|
metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, metricsHandler)
|
||||||
|
if err != nil {
|
||||||
|
if webServer != nil {
|
||||||
|
_ = webServer.Close()
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s := &Service{
|
s := &Service{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
auth: authRuntime,
|
auth: authRuntime,
|
||||||
webServer: webServer,
|
webServer: webServer,
|
||||||
|
metricsServer: metricsServer,
|
||||||
common: options.Common,
|
common: options.Common,
|
||||||
reloadCommon: options.Common,
|
reloadCommon: options.Common,
|
||||||
configFilePath: options.ConfigFilePath,
|
configFilePath: options.ConfigFilePath,
|
||||||
@@ -221,8 +246,20 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
|
|
||||||
func (svr *Service) Run(ctx context.Context) error {
|
func (svr *Service) Run(ctx context.Context) error {
|
||||||
ctx, cancel := context.WithCancelCause(ctx)
|
ctx, cancel := context.WithCancelCause(ctx)
|
||||||
|
svr.lifecycleMu.Lock()
|
||||||
|
if svr.closed {
|
||||||
|
svr.lifecycleMu.Unlock()
|
||||||
|
cancel(nil)
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))
|
svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))
|
||||||
svr.cancel = cancel
|
svr.cancel = cancel
|
||||||
|
svr.lifecycleMu.Unlock()
|
||||||
|
defer cancel(nil)
|
||||||
|
defer svr.stop()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// set custom DNSServer
|
// set custom DNSServer
|
||||||
if svr.common.DNSServer != "" {
|
if svr.common.DNSServer != "" {
|
||||||
@@ -233,7 +270,6 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
vnetController := svr.vnetController
|
vnetController := svr.vnetController
|
||||||
if err := svr.vnetController.Init(); err != nil {
|
if err := svr.vnetController.Init(); err != nil {
|
||||||
log.Errorf("init virtual network controller error: %v", err)
|
log.Errorf("init virtual network controller error: %v", err)
|
||||||
svr.stop()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -245,6 +281,12 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if svr.webServer != nil {
|
if svr.webServer != nil {
|
||||||
|
if svr.common.WebServer.PprofEnable {
|
||||||
|
log.Warnf("webServer pprof endpoints are deprecated; use metricsServer.pprofEnable instead")
|
||||||
|
}
|
||||||
|
if svr.common.MetricsServer.Port == 0 {
|
||||||
|
log.Infof("webServer /healthz is deprecated; health probes can migrate to metricsServer.port")
|
||||||
|
}
|
||||||
webServer := svr.webServer
|
webServer := svr.webServer
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("admin server listen on %s", webServer.Address())
|
log.Infof("admin server listen on %s", webServer.Address())
|
||||||
@@ -254,24 +296,40 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if svr.metricsServer != nil {
|
||||||
|
metricsServer := svr.metricsServer
|
||||||
|
go func() {
|
||||||
|
log.Infof("metrics server listen on %s", metricsServer.Address())
|
||||||
|
if err := metricsServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Warnf("metrics server exit with error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// first login to frps
|
// first login to frps
|
||||||
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
|
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
|
||||||
if svr.ctl == nil {
|
if svr.ctl == nil {
|
||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = 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()
|
go svr.keepControllerWorking()
|
||||||
|
|
||||||
<-svr.ctx.Done()
|
<-svr.ctx.Done()
|
||||||
svr.stop()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) keepControllerWorking() {
|
func (svr *Service) keepControllerWorking() {
|
||||||
<-svr.ctl.Done()
|
// ctl is swapped under ctlMu in loopLoginUntilSuccess / stop; take a
|
||||||
|
// snapshot before waiting so stop's nil write does not race this read.
|
||||||
|
svr.ctlMu.RLock()
|
||||||
|
ctl := svr.ctl
|
||||||
|
svr.ctlMu.RUnlock()
|
||||||
|
if ctl == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
<-ctl.Done()
|
||||||
|
|
||||||
// There is a situation where the login is successful but due to certain reasons,
|
// There is a situation where the login is successful but due to certain reasons,
|
||||||
// the control immediately exits. It is necessary to limit the frequency of reconnection in this case.
|
// the control immediately exits. It is necessary to limit the frequency of reconnection in this case.
|
||||||
@@ -281,8 +339,11 @@ func (svr *Service) keepControllerWorking() {
|
|||||||
// loopLoginUntilSuccess is another layer of loop that will continuously attempt to
|
// loopLoginUntilSuccess is another layer of loop that will continuously attempt to
|
||||||
// login to the server until successful.
|
// login to the server until successful.
|
||||||
svr.loopLoginUntilSuccess(20*time.Second, false)
|
svr.loopLoginUntilSuccess(20*time.Second, false)
|
||||||
if svr.ctl != nil {
|
svr.ctlMu.RLock()
|
||||||
<-svr.ctl.Done()
|
ctl := svr.ctl
|
||||||
|
svr.ctlMu.RUnlock()
|
||||||
|
if ctl != nil {
|
||||||
|
<-ctl.Done()
|
||||||
return false, errors.New("control is closed and try another loop")
|
return false, errors.New("control is closed and try another loop")
|
||||||
}
|
}
|
||||||
// If the control is nil, it means that the login failed and the service is also closed.
|
// If the control is nil, it means that the login failed and the service is also closed.
|
||||||
@@ -305,7 +366,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
xl := xlog.FromContextSafe(svr.ctx)
|
xl := xlog.FromContextSafe(svr.ctx)
|
||||||
|
|
||||||
loginFunc := func() (bool, error) {
|
loginFunc := func() (bool, error) {
|
||||||
xl.Infof("尝试连接到服务器...")
|
xl.Infof("try to connect to server...")
|
||||||
dialer := &controlSessionDialer{
|
dialer := &controlSessionDialer{
|
||||||
ctx: svr.ctx,
|
ctx: svr.ctx,
|
||||||
common: svr.common,
|
common: svr.common,
|
||||||
@@ -316,7 +377,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
}
|
}
|
||||||
sessionCtx, err := dialer.Dial(svr.runID)
|
sessionCtx, err := dialer.Dial(svr.runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("连接服务器错误: %v", err)
|
xl.Warnf("connect to server error: %v", err)
|
||||||
if firstLoginExit {
|
if firstLoginExit {
|
||||||
svr.cancel(cancelErr{Err: err})
|
svr.cancel(cancelErr{Err: err})
|
||||||
}
|
}
|
||||||
@@ -327,11 +388,6 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
||||||
xl.Infof("login to server success, get run id [%s]", svr.runID)
|
xl.Infof("login to server success, get run id [%s]", svr.runID)
|
||||||
|
|
||||||
svr.cfgMu.RLock()
|
|
||||||
proxyCfgs := svr.proxyCfgs
|
|
||||||
visitorCfgs := svr.visitorCfgs
|
|
||||||
svr.cfgMu.RUnlock()
|
|
||||||
|
|
||||||
ctl, err := NewControl(svr.ctx, sessionCtx)
|
ctl, err := NewControl(svr.ctx, sessionCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sessionCtx.Conn.Close()
|
sessionCtx.Conn.Close()
|
||||||
@@ -341,6 +397,15 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
}
|
}
|
||||||
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
|
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
|
||||||
|
|
||||||
|
// An update must either precede this snapshot or apply to the published
|
||||||
|
// control. Keep dialing and login outside the reload transaction.
|
||||||
|
svr.reloadMu.Lock()
|
||||||
|
defer svr.reloadMu.Unlock()
|
||||||
|
svr.cfgMu.RLock()
|
||||||
|
proxyCfgs := svr.proxyCfgs
|
||||||
|
visitorCfgs := svr.visitorCfgs
|
||||||
|
svr.cfgMu.RUnlock()
|
||||||
|
|
||||||
ctl.Run(proxyCfgs, visitorCfgs)
|
ctl.Run(proxyCfgs, visitorCfgs)
|
||||||
// close and replace previous control
|
// close and replace previous control
|
||||||
svr.ctlMu.Lock()
|
svr.ctlMu.Lock()
|
||||||
@@ -363,6 +428,13 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
|
func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
|
||||||
|
svr.reloadMu.Lock()
|
||||||
|
defer svr.reloadMu.Unlock()
|
||||||
|
return svr.updateAllConfigurerLocked(proxyCfgs, visitorCfgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller must hold reloadMu.
|
||||||
|
func (svr *Service) updateAllConfigurerLocked(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
|
||||||
svr.cfgMu.Lock()
|
svr.cfgMu.Lock()
|
||||||
svr.proxyCfgs = proxyCfgs
|
svr.proxyCfgs = proxyCfgs
|
||||||
svr.visitorCfgs = visitorCfgs
|
svr.visitorCfgs = visitorCfgs
|
||||||
@@ -373,7 +445,7 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
|
|||||||
svr.ctlMu.RUnlock()
|
svr.ctlMu.RUnlock()
|
||||||
|
|
||||||
if ctl != nil {
|
if ctl != nil {
|
||||||
return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
|
return ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -386,6 +458,15 @@ func (svr *Service) UpdateConfigSource(
|
|||||||
svr.reloadMu.Lock()
|
svr.reloadMu.Lock()
|
||||||
defer svr.reloadMu.Unlock()
|
defer svr.reloadMu.Unlock()
|
||||||
|
|
||||||
|
return svr.updateConfigSourceLocked(common, proxyCfgs, visitorCfgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller must hold reloadMu.
|
||||||
|
func (svr *Service) updateConfigSourceLocked(
|
||||||
|
common *v1.ClientCommonConfig,
|
||||||
|
proxyCfgs []v1.ProxyConfigurer,
|
||||||
|
visitorCfgs []v1.VisitorConfigurer,
|
||||||
|
) error {
|
||||||
cfgSource := svr.configSource
|
cfgSource := svr.configSource
|
||||||
if cfgSource == nil {
|
if cfgSource == nil {
|
||||||
return fmt.Errorf("config source is not available")
|
return fmt.Errorf("config source is not available")
|
||||||
@@ -412,8 +493,21 @@ func (svr *Service) Close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) GracefulClose(d time.Duration) {
|
func (svr *Service) GracefulClose(d time.Duration) {
|
||||||
svr.gracefulShutdownDuration = d
|
svr.gracefulShutdownDuration.Store(int64(d))
|
||||||
svr.cancel(nil)
|
svr.lifecycleMu.Lock()
|
||||||
|
if svr.closed {
|
||||||
|
svr.lifecycleMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svr.closed = true
|
||||||
|
cancel := svr.cancel
|
||||||
|
svr.lifecycleMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
// Once Run has claimed startup, it also owns cleanup.
|
||||||
|
cancel(nil)
|
||||||
|
} else {
|
||||||
|
svr.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) stop() {
|
func (svr *Service) stop() {
|
||||||
@@ -429,13 +523,18 @@ func (svr *Service) stop() {
|
|||||||
svr.ctlMu.Lock()
|
svr.ctlMu.Lock()
|
||||||
defer svr.ctlMu.Unlock()
|
defer svr.ctlMu.Unlock()
|
||||||
if svr.ctl != nil {
|
if svr.ctl != nil {
|
||||||
svr.ctl.GracefulClose(svr.gracefulShutdownDuration)
|
d := time.Duration(svr.gracefulShutdownDuration.Load())
|
||||||
|
svr.ctl.GracefulClose(d)
|
||||||
svr.ctl = nil
|
svr.ctl = nil
|
||||||
}
|
}
|
||||||
if svr.webServer != nil {
|
if svr.webServer != nil {
|
||||||
svr.webServer.Close()
|
svr.webServer.Close()
|
||||||
svr.webServer = nil
|
svr.webServer = nil
|
||||||
}
|
}
|
||||||
|
if svr.metricsServer != nil {
|
||||||
|
svr.metricsServer.Close()
|
||||||
|
svr.metricsServer = nil
|
||||||
|
}
|
||||||
if svr.vnetController != nil {
|
if svr.vnetController != nil {
|
||||||
_ = svr.vnetController.Stop()
|
_ = svr.vnetController.Stop()
|
||||||
svr.vnetController = nil
|
svr.vnetController = nil
|
||||||
@@ -506,9 +605,16 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
|
|||||||
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
|
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
|
||||||
proxies = config.CompleteProxyConfigurers(proxies)
|
proxies = config.CompleteProxyConfigurers(proxies)
|
||||||
visitors = config.CompleteVisitorConfigurers(visitors)
|
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
|
// Atomically replace the entire configuration
|
||||||
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
|
if err := svr.updateAllConfigurerLocked(proxies, visitors); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLifecycleTestService(t *testing.T, creator func(context.Context, *v1.ClientCommonConfig) Connector) (*Service, string) {
|
||||||
|
t.Helper()
|
||||||
|
svr, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
LoginFailExit: new(false),
|
||||||
|
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: getFreeTCPPort(t)},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||||
|
ConnectorCreator: creator,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = validation.NewConfigValidator(nil).ValidateClientCommonConfig(svr.common)
|
||||||
|
require.NoError(t, err)
|
||||||
|
address := svr.webServer.Address()
|
||||||
|
assertLifecycleAdminPort(t, address, false)
|
||||||
|
return svr, address
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLifecycleAdminPort(t *testing.T, address string, released bool) {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", address)
|
||||||
|
if ln != nil {
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
if released {
|
||||||
|
require.NoError(t, err, "admin listener was not released")
|
||||||
|
} else {
|
||||||
|
require.Error(t, err, "admin listener was not bound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitLifecycleSignal(t *testing.T, done <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("service lifecycle operation did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
|
||||||
|
for _, mode := range []string{"close before Run", "parent already cancelled"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
var attempts atomic.Int64
|
||||||
|
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||||
|
attempts.Add(1)
|
||||||
|
return &failingConnector{err: net.ErrClosed}
|
||||||
|
})
|
||||||
|
// Detect startup's global DNS side effect without making DNS requests.
|
||||||
|
resolver := net.DefaultResolver
|
||||||
|
t.Cleanup(func() { net.DefaultResolver = resolver })
|
||||||
|
svr.common.DNSServer = "127.0.0.1:53"
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
if mode == "close before Run" {
|
||||||
|
svr.Close()
|
||||||
|
assertLifecycleAdminPort(t, address, true)
|
||||||
|
} else {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
var runErr error
|
||||||
|
go func() {
|
||||||
|
runErr = svr.Run(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
waitLifecycleSignal(t, done)
|
||||||
|
require.ErrorIs(t, runErr, context.Canceled)
|
||||||
|
require.Zero(t, attempts.Load(), "closed service attempted login")
|
||||||
|
require.Same(t, resolver, net.DefaultResolver, "closed service changed DNS")
|
||||||
|
assertLifecycleAdminPort(t, address, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceStartupShutdownLeavesCleanupToRun(t *testing.T) {
|
||||||
|
for _, mode := range []string{"Close", "parent cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
entered, resume := make(chan struct{}), make(chan struct{})
|
||||||
|
svr, address := newLifecycleTestService(t, func(ctx context.Context, _ *v1.ClientCommonConfig) Connector {
|
||||||
|
close(entered)
|
||||||
|
<-resume
|
||||||
|
return &failingConnector{err: ctx.Err()}
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
defer close(resume)
|
||||||
|
runDone := make(chan struct{})
|
||||||
|
go func() { _ = svr.Run(ctx); close(runDone) }()
|
||||||
|
waitLifecycleSignal(t, entered)
|
||||||
|
closeDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
if mode == "Close" {
|
||||||
|
svr.Close()
|
||||||
|
} else {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
close(closeDone)
|
||||||
|
}()
|
||||||
|
waitLifecycleSignal(t, closeDone)
|
||||||
|
select {
|
||||||
|
case <-runDone:
|
||||||
|
t.Fatal("Run exited while startup still owned its resources")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
assertLifecycleAdminPort(t, address, false)
|
||||||
|
resume <- struct{}{}
|
||||||
|
waitLifecycleSignal(t, runDone)
|
||||||
|
assertLifecycleAdminPort(t, address, true)
|
||||||
|
require.ErrorIs(t, svr.ctx.Err(), context.Canceled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceShutdownAfterLogin(t *testing.T) {
|
||||||
|
for _, mode := range []string{"Close", "parent cancellation"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
connector := &testConnector{conn: clientConn}
|
||||||
|
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||||
|
return connector
|
||||||
|
})
|
||||||
|
defer svr.Close()
|
||||||
|
peerDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
rw := msg.NewV1ReadWriter(serverConn)
|
||||||
|
var login msg.Login
|
||||||
|
if err := rw.ReadMsgInto(&login); err != nil {
|
||||||
|
peerDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rw.WriteMsg(&msg.LoginResp{RunID: "lifecycle-test"}); err != nil {
|
||||||
|
peerDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := io.Copy(io.Discard, serverConn)
|
||||||
|
peerDone <- err
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
runDone := make(chan struct{})
|
||||||
|
var runErr error
|
||||||
|
go func() { runErr = svr.Run(ctx); close(runDone) }()
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
svr.ctlMu.RLock()
|
||||||
|
defer svr.ctlMu.RUnlock()
|
||||||
|
return svr.ctl != nil
|
||||||
|
}, 3*time.Second, time.Millisecond, "login did not establish control")
|
||||||
|
if mode == "Close" {
|
||||||
|
svr.Close()
|
||||||
|
} else {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
waitLifecycleSignal(t, runDone)
|
||||||
|
require.NoError(t, runErr)
|
||||||
|
require.True(t, connector.closed.Load())
|
||||||
|
select {
|
||||||
|
case err := <-peerDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("control connection was not released")
|
||||||
|
}
|
||||||
|
assertLifecycleAdminPort(t, address, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/client/proxy"
|
||||||
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block proxy construction after the service has read its config snapshot, but
|
||||||
|
// before Control.Run returns and the replacement control is published.
|
||||||
|
type handoffProxyConfig struct {
|
||||||
|
*v1.TCPProxyConfig
|
||||||
|
started chan struct{}
|
||||||
|
resume chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
proxy.RegisterProxyFactory(reflect.TypeFor[*handoffProxyConfig](), func(base *proxy.BaseProxy, cfg v1.ProxyConfigurer) proxy.Proxy {
|
||||||
|
blocking := cfg.(*handoffProxyConfig)
|
||||||
|
close(blocking.started)
|
||||||
|
<-blocking.resume
|
||||||
|
return proxy.NewGeneralTCPProxy(base, blocking.TCPProxyConfig)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReloadTestService(t *testing.T, beforeLogin func()) *Service {
|
||||||
|
t.Helper()
|
||||||
|
svr, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
Transport: v1.ClientTransportConfig{HeartbeatInterval: -1, HeartbeatTimeout: -1},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||||
|
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
peerDone := make(chan struct{})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = clientConn.Close()
|
||||||
|
_ = serverConn.Close()
|
||||||
|
waitLifecycleSignal(t, peerDone)
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
defer close(peerDone)
|
||||||
|
rw := msg.NewV1ReadWriter(serverConn)
|
||||||
|
var login msg.Login
|
||||||
|
if err := rw.ReadMsgInto(&login); err != nil {
|
||||||
|
t.Errorf("read login: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if beforeLogin != nil {
|
||||||
|
beforeLogin()
|
||||||
|
}
|
||||||
|
if err := rw.WriteMsg(&msg.LoginResp{RunID: "reload-test"}); err != nil {
|
||||||
|
t.Errorf("write login response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, serverConn)
|
||||||
|
}()
|
||||||
|
return &testConnector{conn: clientConn}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
svr.ctx, svr.cancel = context.WithCancelCause(context.Background())
|
||||||
|
t.Cleanup(func() {
|
||||||
|
svr.cancel(nil)
|
||||||
|
if svr.ctl != nil {
|
||||||
|
_ = svr.ctl.Close()
|
||||||
|
waitLifecycleSignal(t, svr.ctl.Done())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return svr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceReloadDuringControlHandoff(t *testing.T) {
|
||||||
|
for _, updateSource := range []bool{false, true} {
|
||||||
|
name := "direct update"
|
||||||
|
if updateSource {
|
||||||
|
name = "config source reload"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
svr := newReloadTestService(t, nil)
|
||||||
|
svr.loopLoginUntilSuccess(time.Second, true)
|
||||||
|
previous := svr.ctl
|
||||||
|
require.NotNil(t, previous)
|
||||||
|
require.NoError(t, previous.Close())
|
||||||
|
waitLifecycleSignal(t, previous.Done())
|
||||||
|
|
||||||
|
oldProxy := &handoffProxyConfig{
|
||||||
|
TCPProxyConfig: &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
|
||||||
|
RemotePort: 18001,
|
||||||
|
},
|
||||||
|
started: make(chan struct{}),
|
||||||
|
resume: make(chan struct{}),
|
||||||
|
}
|
||||||
|
oldVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||||
|
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "old-target",
|
||||||
|
}}
|
||||||
|
// Seed the desired config for the next connection without restarting
|
||||||
|
// proxies on the already closed control.
|
||||||
|
svr.proxyCfgs = []v1.ProxyConfigurer{oldProxy}
|
||||||
|
svr.visitorCfgs = []v1.VisitorConfigurer{oldVisitor}
|
||||||
|
loginDone, updateDone := make(chan struct{}), make(chan struct{})
|
||||||
|
resume := sync.OnceFunc(func() { close(oldProxy.resume) })
|
||||||
|
t.Cleanup(func() {
|
||||||
|
resume()
|
||||||
|
waitLifecycleSignal(t, loginDone)
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
svr.loopLoginUntilSuccess(time.Second, true)
|
||||||
|
close(loginDone)
|
||||||
|
}()
|
||||||
|
waitLifecycleSignal(t, oldProxy.started)
|
||||||
|
svr.ctlMu.RLock()
|
||||||
|
current := svr.ctl
|
||||||
|
svr.ctlMu.RUnlock()
|
||||||
|
require.Same(t, previous, current, "replacement must still be unpublished")
|
||||||
|
// Check before starting the updater so it cannot be the lock owner.
|
||||||
|
unlocked := svr.reloadMu.TryLock()
|
||||||
|
if unlocked {
|
||||||
|
svr.reloadMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
newProxy := &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
|
||||||
|
RemotePort: 18002,
|
||||||
|
}
|
||||||
|
newVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||||
|
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "new-target",
|
||||||
|
}}
|
||||||
|
var updateErr error
|
||||||
|
go func() {
|
||||||
|
proxies, visitors := []v1.ProxyConfigurer{newProxy}, []v1.VisitorConfigurer{newVisitor}
|
||||||
|
if updateSource {
|
||||||
|
updateErr = svr.UpdateConfigSource(svr.common, proxies, visitors)
|
||||||
|
} else {
|
||||||
|
updateErr = svr.UpdateAllConfigurer(proxies, visitors)
|
||||||
|
}
|
||||||
|
close(updateDone)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
resume()
|
||||||
|
waitLifecycleSignal(t, updateDone)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The gate fixes the handoff window. On the old implementation the
|
||||||
|
// update completes against the retired control before this gate opens.
|
||||||
|
select {
|
||||||
|
case <-updateDone:
|
||||||
|
t.Error("config update completed before control handoff")
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
require.False(t, unlocked, "control startup must hold the reload transaction until publication")
|
||||||
|
resume()
|
||||||
|
waitLifecycleSignal(t, loginDone)
|
||||||
|
waitLifecycleSignal(t, updateDone)
|
||||||
|
require.NoError(t, updateErr)
|
||||||
|
require.NotSame(t, previous, svr.ctl)
|
||||||
|
status, ok := svr.getProxyStatus("proxy")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 18002, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
|
||||||
|
visitor, ok := svr.getVisitorCfg("visitor")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "new-target", visitor.GetBaseConfig().ServerName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceReloadWhileLoginPending(t *testing.T) {
|
||||||
|
loginStarted, allowLogin, loginDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
resume := sync.OnceFunc(func() { close(allowLogin) })
|
||||||
|
svr := newReloadTestService(t, func() {
|
||||||
|
close(loginStarted)
|
||||||
|
<-allowLogin
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
resume()
|
||||||
|
waitLifecycleSignal(t, loginDone)
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
svr.loopLoginUntilSuccess(time.Second, true)
|
||||||
|
close(loginDone)
|
||||||
|
}()
|
||||||
|
waitLifecycleSignal(t, loginStarted)
|
||||||
|
|
||||||
|
proxyCfg := &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "during-login", Type: "tcp"},
|
||||||
|
RemotePort: 18003,
|
||||||
|
}
|
||||||
|
updateDone := make(chan struct{})
|
||||||
|
var updateErr error
|
||||||
|
go func() {
|
||||||
|
updateErr = svr.UpdateAllConfigurer([]v1.ProxyConfigurer{proxyCfg}, nil)
|
||||||
|
close(updateDone)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
resume()
|
||||||
|
waitLifecycleSignal(t, updateDone)
|
||||||
|
})
|
||||||
|
// Config updates must complete even while the server has not replied to login.
|
||||||
|
waitLifecycleSignal(t, updateDone)
|
||||||
|
require.NoError(t, updateErr)
|
||||||
|
resume()
|
||||||
|
waitLifecycleSignal(t, loginDone)
|
||||||
|
status, ok := svr.getProxyStatus("during-login")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 18003, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -49,7 +47,7 @@ func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
|
|||||||
|
|
||||||
svr, err := NewService(ServiceOptions{
|
svr, err := NewService(ServiceOptions{
|
||||||
Common: &v1.ClientCommonConfig{
|
Common: &v1.ClientCommonConfig{
|
||||||
LoginFailExit: lo.ToPtr(true),
|
LoginFailExit: new(true),
|
||||||
WebServer: v1.WebServerConfig{
|
WebServer: v1.WebServerConfig{
|
||||||
Addr: "127.0.0.1",
|
Addr: "127.0.0.1",
|
||||||
Port: port,
|
Port: port,
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ func (sv *SUDPVisitor) dispatcher() {
|
|||||||
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
xl := xlog.FromContextSafe(sv.ctx)
|
||||||
xl.Debugf("starting sudp proxy worker")
|
xl.Debugf("starting sudp proxy worker")
|
||||||
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
|
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, sv.clientCfg.Transport.WireProtocol, udpPacketCodecFromHelper(sv.helper))
|
||||||
|
if err != nil {
|
||||||
|
xl.Errorf("create SUDP packet read writer: %v", err)
|
||||||
|
_ = workConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payloadConn := msg.NewConn(workConn, payloadRW)
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ type Helper interface {
|
|||||||
RunID() string
|
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.
|
// Visitor is used for forward traffics from local port tot remote service.
|
||||||
type Visitor interface {
|
type Visitor interface {
|
||||||
Run() error
|
Run() error
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ func NewManager(
|
|||||||
connectServer func() (*msg.Conn, error),
|
connectServer func() (*msg.Conn, error),
|
||||||
msgTransporter transport.MessageTransporter,
|
msgTransporter transport.MessageTransporter,
|
||||||
vnetController *vnet.Controller,
|
vnetController *vnet.Controller,
|
||||||
|
udpPacketCodecs ...string,
|
||||||
) *Manager {
|
) *Manager {
|
||||||
|
udpPacketCodec := ""
|
||||||
|
if len(udpPacketCodecs) > 0 {
|
||||||
|
udpPacketCodec = udpPacketCodecs[0]
|
||||||
|
}
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
cfgs: make(map[string]v1.VisitorConfigurer),
|
cfgs: make(map[string]v1.VisitorConfigurer),
|
||||||
@@ -68,6 +73,7 @@ func NewManager(
|
|||||||
vnetController: vnetController,
|
vnetController: vnetController,
|
||||||
transferConnFn: m.TransferConn,
|
transferConnFn: m.TransferConn,
|
||||||
runID: runID,
|
runID: runID,
|
||||||
|
udpPacketCodec: udpPacketCodec,
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -205,6 +211,7 @@ type visitorHelperImpl struct {
|
|||||||
vnetController *vnet.Controller
|
vnetController *vnet.Controller
|
||||||
transferConnFn func(name string, conn net.Conn) error
|
transferConnFn func(name string, conn net.Conn) error
|
||||||
runID string
|
runID string
|
||||||
|
udpPacketCodec string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
|
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
|
||||||
@@ -226,3 +233,7 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller {
|
|||||||
func (v *visitorHelperImpl) RunID() string {
|
func (v *visitorHelperImpl) RunID() string {
|
||||||
return v.runID
|
return v.runID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitorHelperImpl) UDPPacketCodec() string {
|
||||||
|
return v.udpPacketCodec
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
|
|||||||
Use: name,
|
Use: name,
|
||||||
Short: short,
|
Short: short,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
fmt.Println("frpc: the configuration file is not specified")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+1
-12
@@ -48,24 +48,13 @@ var natholeDiscoveryCmd = &cobra.Command{
|
|||||||
Short: "Discover nathole information from stun server",
|
Short: "Discover nathole information from stun server",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// ignore error here, because we can use command line parameters
|
// ignore error here, because we can use command line parameters
|
||||||
var cfg *v1.ClientCommonConfig
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
|
|
||||||
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg = &v1.ClientCommonConfig{}
|
cfg = &v1.ClientCommonConfig{}
|
||||||
if err := cfg.Complete(); err != nil {
|
if err := cfg.Complete(); err != nil {
|
||||||
fmt.Printf("failed to complete config: %v\n", err)
|
fmt.Printf("failed to complete config: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
cfg = loaded
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cfg = &v1.ClientCommonConfig{}
|
|
||||||
if err := cfg.Complete(); err != nil {
|
|
||||||
fmt.Printf("failed to complete config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if natHoleSTUNServer != "" {
|
if natHoleSTUNServer != "" {
|
||||||
cfg.NatHoleSTUNServer = natHoleSTUNServer
|
cfg.NatHoleSTUNServer = natHoleSTUNServer
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "")
|
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "")
|
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -141,13 +141,11 @@ func startService(
|
|||||||
visitorCfgs []v1.VisitorConfigurer,
|
visitorCfgs []v1.VisitorConfigurer,
|
||||||
unsafeFeatures *security.UnsafeFeatures,
|
unsafeFeatures *security.UnsafeFeatures,
|
||||||
cfgFile string,
|
cfgFile string,
|
||||||
nodeName string,
|
|
||||||
tunnelRemark string,
|
|
||||||
) error {
|
) error {
|
||||||
configSource := source.NewConfigSource()
|
configSource := source.NewConfigSource()
|
||||||
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
||||||
return fmt.Errorf("failed to set config source: %w", err)
|
return fmt.Errorf("failed to set config source: %w", err)
|
||||||
}
|
}
|
||||||
aggregator := source.NewAggregator(configSource)
|
aggregator := source.NewAggregator(configSource)
|
||||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
|
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-259
@@ -16,11 +16,8 @@ package sub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -36,30 +33,25 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
"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/policy/security"
|
||||||
"github.com/fatedier/frp/pkg/util/banner"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFiles []string
|
cfgFile string
|
||||||
cfgDir string
|
cfgDir string
|
||||||
showVersion bool
|
showVersion bool
|
||||||
strictConfigMode bool
|
strictConfigMode bool
|
||||||
allowUnsafe []string
|
allowUnsafe []string
|
||||||
authTokens []string
|
|
||||||
|
|
||||||
bannerDisplayed bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
|
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
rootCmd.PersistentFlags().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(&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().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{},
|
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
||||||
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
||||||
}
|
}
|
||||||
@@ -75,30 +67,15 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
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.
|
// 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 != "" {
|
if cfgDir != "" {
|
||||||
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
||||||
return nil
|
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.
|
// Do not show command usage here.
|
||||||
err := runClient(cfgFiles[0], unsafeFeatures)
|
err := runClient(cfgFile, unsafeFeatures)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -128,29 +105,6 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
|
|||||||
return err
|
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() {
|
func Execute() {
|
||||||
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
@@ -176,12 +130,6 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
|
|||||||
"please use yaml/json/toml format instead!\n")
|
"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)
|
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +179,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "")
|
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServiceWithAggregator(
|
func startServiceWithAggregator(
|
||||||
@@ -239,25 +187,12 @@ func startServiceWithAggregator(
|
|||||||
aggregator *source.Aggregator,
|
aggregator *source.Aggregator,
|
||||||
unsafeFeatures *security.UnsafeFeatures,
|
unsafeFeatures *security.UnsafeFeatures,
|
||||||
cfgFile string,
|
cfgFile string,
|
||||||
nodeName string,
|
|
||||||
tunnelRemark string,
|
|
||||||
) error {
|
) error {
|
||||||
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
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 != "" {
|
if cfgFile != "" {
|
||||||
log.Infof("启动 frpc 服务 [%s]", cfgFile)
|
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
|
||||||
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
|
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
|
||||||
}
|
}
|
||||||
svr, err := client.NewService(client.ServiceOptions{
|
svr, err := client.NewService(client.ServiceOptions{
|
||||||
Common: cfg,
|
Common: cfg,
|
||||||
@@ -275,189 +210,3 @@ func startServiceWithAggregator(
|
|||||||
}
|
}
|
||||||
return svr.Run(context.Background())
|
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)
|
|
||||||
|
|
||||||
if len(cfg.FeatureGates) > 0 {
|
|
||||||
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-8
@@ -29,23 +29,29 @@ func init() {
|
|||||||
rootCmd.AddCommand(verifyCmd)
|
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{
|
var verifyCmd = &cobra.Command{
|
||||||
Use: "verify",
|
Use: "verify",
|
||||||
Short: "Verify that the configures is valid",
|
Short: "Verify that the configures is valid",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
if cfgFile == "" {
|
||||||
fmt.Println("frpc: the configuration file is not specified")
|
fmt.Println("frpc: the configuration file is not specified")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgFile := cfgFiles[0]
|
|
||||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
||||||
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
|
||||||
if warning != nil {
|
if warning != nil {
|
||||||
fmt.Printf("WARNING: %v\n", warning)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-37
@@ -79,9 +79,23 @@ webServer.password = "admin"
|
|||||||
# Admin assets directory. By default, these assets are bundled with frpc.
|
# Admin assets directory. By default, these assets are bundled with frpc.
|
||||||
# webServer.assetsDir = "./static"
|
# webServer.assetsDir = "./static"
|
||||||
|
|
||||||
|
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
|
||||||
# Enable golang pprof handlers in admin listener.
|
# Enable golang pprof handlers in admin listener.
|
||||||
webServer.pprofEnable = false
|
webServer.pprofEnable = false
|
||||||
|
|
||||||
|
# Separate health, metrics and optional profiling listener.
|
||||||
|
# /metrics serves Go runtime metrics only (no proxy or traffic data).
|
||||||
|
# Port 0 disables it. The default address only accepts local connections.
|
||||||
|
metricsServer.addr = "127.0.0.1"
|
||||||
|
metricsServer.port = 0
|
||||||
|
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
|
||||||
|
# metricsServer.user = "monitor"
|
||||||
|
# metricsServer.password = "change-me"
|
||||||
|
# metricsServer.tls.certFile = "metrics.crt"
|
||||||
|
# metricsServer.tls.keyFile = "metrics.key"
|
||||||
|
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
|
||||||
|
metricsServer.pprofEnable = false
|
||||||
|
|
||||||
# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.
|
# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.
|
||||||
# transport.dialServerTimeout = 10
|
# transport.dialServerTimeout = 10
|
||||||
|
|
||||||
@@ -131,6 +145,12 @@ transport.tls.enable = true
|
|||||||
# transport.tls.trustedCaFile = "ca.crt"
|
# transport.tls.trustedCaFile = "ca.crt"
|
||||||
# transport.tls.serverName = "example.com"
|
# transport.tls.serverName = "example.com"
|
||||||
|
|
||||||
|
# If useSystemCa is true, frpc verifies the server certificate using system CAs.
|
||||||
|
# If trustedCaFile is also set, its CA certificates are added to the system CA pool.
|
||||||
|
# Only supported in frpc transport.tls; requires tls.enable = true unless protocol is wss.
|
||||||
|
# CA loading or certificate verification failures cause the connection to fail.
|
||||||
|
# transport.tls.useSystemCa = false
|
||||||
|
|
||||||
# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the
|
# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the
|
||||||
# first custom byte when tls is enabled.
|
# first custom byte when tls is enabled.
|
||||||
# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.
|
# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.
|
||||||
@@ -275,10 +295,6 @@ localIP = "127.0.0.1"
|
|||||||
localPort = 8000
|
localPort = 8000
|
||||||
subdomain = "web02"
|
subdomain = "web02"
|
||||||
customDomains = ["web02.yourdomain.com"]
|
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
|
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
|
||||||
# v1 or v2 or empty
|
# v1 or v2 or empty
|
||||||
transport.proxyProtocolVersion = "v2"
|
transport.proxyProtocolVersion = "v2"
|
||||||
@@ -340,14 +356,6 @@ type = "https2http"
|
|||||||
localAddr = "127.0.0.1:80"
|
localAddr = "127.0.0.1:80"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
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"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
@@ -360,14 +368,6 @@ type = "https2https"
|
|||||||
localAddr = "127.0.0.1:443"
|
localAddr = "127.0.0.1:443"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
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"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
@@ -381,15 +381,6 @@ localAddr = "127.0.0.1:443"
|
|||||||
hostHeaderRewrite = "127.0.0.1"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
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]]
|
[[proxies]]
|
||||||
name = "plugin_http2http"
|
name = "plugin_http2http"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
@@ -409,14 +400,6 @@ type = "tls2raw"
|
|||||||
localAddr = "127.0.0.1:80"
|
localAddr = "127.0.0.1:80"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
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]]
|
[[proxies]]
|
||||||
name = "secret_tcp"
|
name = "secret_tcp"
|
||||||
|
|||||||
+15
-13
@@ -40,12 +40,6 @@ transport.maxPoolCount = 5
|
|||||||
# If negative, keep-alive probes are disabled.
|
# If negative, keep-alive probes are disabled.
|
||||||
# transport.tcpKeepalive = 7200
|
# 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 specifies whether to only accept TLS-encrypted connections. By default, the value is false.
|
||||||
transport.tls.force = false
|
transport.tls.force = false
|
||||||
|
|
||||||
@@ -58,12 +52,6 @@ transport.tls.force = false
|
|||||||
vhostHTTPPort = 80
|
vhostHTTPPort = 80
|
||||||
vhostHTTPSPort = 443
|
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
|
# Response header timeout(seconds) for vhost http server, default is 60s
|
||||||
# vhostHTTPTimeout = 60
|
# vhostHTTPTimeout = 60
|
||||||
|
|
||||||
@@ -87,13 +75,27 @@ webServer.password = "admin"
|
|||||||
# dashboard assets directory(only for debug mode)
|
# dashboard assets directory(only for debug mode)
|
||||||
# webServer.assetsDir = "./static"
|
# webServer.assetsDir = "./static"
|
||||||
|
|
||||||
|
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
|
||||||
# Enable golang pprof handlers in dashboard listener.
|
# Enable golang pprof handlers in dashboard listener.
|
||||||
# Dashboard port must be set first
|
# Dashboard port must be set first
|
||||||
webServer.pprofEnable = false
|
webServer.pprofEnable = false
|
||||||
|
|
||||||
# enablePrometheus will export prometheus metrics on webServer in /metrics api.
|
# Deprecated: enablePrometheus exports /metrics on webServer only.
|
||||||
|
# Use metricsServer.port instead; it enables business metrics automatically.
|
||||||
enablePrometheus = true
|
enablePrometheus = true
|
||||||
|
|
||||||
|
# Separate health, Prometheus metrics, and optional profiling listener.
|
||||||
|
# Port 0 disables it. The default address only accepts local connections.
|
||||||
|
metricsServer.addr = "127.0.0.1"
|
||||||
|
metricsServer.port = 0
|
||||||
|
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
|
||||||
|
# metricsServer.user = "monitor"
|
||||||
|
# metricsServer.password = "change-me"
|
||||||
|
# metricsServer.tls.certFile = "metrics.crt"
|
||||||
|
# metricsServer.tls.keyFile = "metrics.key"
|
||||||
|
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
|
||||||
|
metricsServer.pprofEnable = false
|
||||||
|
|
||||||
# console or real logFile path like ./frps.log
|
# console or real logFile path like ./frps.log
|
||||||
log.to = "./frps.log"
|
log.to = "./frps.log"
|
||||||
# trace, debug, info, warn, error
|
# trace, debug, info, warn, error
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ For the version compatibility policy that bounds these support windows, see the
|
|||||||
|
|
||||||
## Active
|
## Active
|
||||||
|
|
||||||
|
### Observability endpoints on `webServer`
|
||||||
|
|
||||||
|
- **Deprecated since:** v0.72.0 (when `metricsServer` was added).
|
||||||
|
- **Removal target:** v0.80.0 or later. v0.71.x is the last line without `metricsServer` and is supported until v0.80.0 is released, so v0.79.x is the last release that must keep these endpoints.
|
||||||
|
- **Replacement:** `metricsServer`. Point health probes at its `/healthz`, scrapes at its `/metrics` (frps exposes business metrics there without `enablePrometheus`), and use `metricsServer.pprofEnable` for profiling.
|
||||||
|
- **Deprecated items:** `/healthz` on `webServer` in both frps and frpc; `/metrics` on the frps `webServer` and the top-level `enablePrometheus` that gates it; the pprof handlers on `webServer` and `webServer.pprofEnable` in both frps and frpc.
|
||||||
|
- **Code references:**
|
||||||
|
- `server/api_router.go` — `/healthz`, and `/metrics` when `enablePrometheus` is set.
|
||||||
|
- `client/api_router.go` — `/healthz`.
|
||||||
|
- `pkg/util/http/server.go` — pprof handlers registered when `webServer.pprofEnable` is set.
|
||||||
|
- `server/service.go` — startup deprecation warnings and `metricsCollectors`.
|
||||||
|
- `client/service.go` — startup deprecation warnings.
|
||||||
|
- `pkg/config/v1/server.go` — `EnablePrometheus`; `pkg/config/v1/common.go` — `WebServerConfig.PprofEnable`.
|
||||||
|
- **Notes:** Removal drops the startup warnings and the deprecation notes in `README.md` and the two `conf/*_full_example.toml` files together. `webServer` then serves only the dashboard and the admin API.
|
||||||
|
|
||||||
### Wire protocol v1
|
### Wire protocol v1
|
||||||
|
|
||||||
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
|
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -1,14 +1,16 @@
|
|||||||
FROM node:22 AS web-builder
|
FROM node:22 AS web-builder
|
||||||
|
|
||||||
COPY web/package.json /web/package.json
|
|
||||||
COPY web/shared/ /web/shared/
|
|
||||||
COPY web/frpc/ /web/frpc/
|
|
||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
RUN npm install
|
COPY web/package.json web/package-lock.json ./
|
||||||
WORKDIR /web/frpc
|
COPY web/shared/package.json ./shared/package.json
|
||||||
RUN npm run build
|
COPY web/frpc/package.json ./frpc/package.json
|
||||||
|
COPY web/frps/package.json ./frps/package.json
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/shared/ ./shared/
|
||||||
|
COPY web/frpc/ ./frpc/
|
||||||
|
RUN npm run build --workspace frpc
|
||||||
|
|
||||||
FROM golang:1.25 AS building
|
FROM golang:1.26 AS building
|
||||||
|
|
||||||
COPY . /building
|
COPY . /building
|
||||||
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
FROM node:22 AS web-builder
|
FROM node:22 AS web-builder
|
||||||
|
|
||||||
COPY web/package.json /web/package.json
|
|
||||||
COPY web/shared/ /web/shared/
|
|
||||||
COPY web/frps/ /web/frps/
|
|
||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
RUN npm install
|
COPY web/package.json web/package-lock.json ./
|
||||||
WORKDIR /web/frps
|
COPY web/shared/package.json ./shared/package.json
|
||||||
RUN npm run build
|
COPY web/frpc/package.json ./frpc/package.json
|
||||||
|
COPY web/frps/package.json ./frps/package.json
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/shared/ ./shared/
|
||||||
|
COPY web/frps/ ./frps/
|
||||||
|
RUN npm run build --workspace frps
|
||||||
|
|
||||||
FROM golang:1.25 AS building
|
FROM golang:1.26 AS building
|
||||||
|
|
||||||
COPY . /building
|
COPY . /building
|
||||||
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
module github.com/fatedier/frp
|
module github.com/fatedier/frp
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/fatedier/golib v0.8.2
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/fatedier/yamux v0.2.0
|
||||||
github.com/fatedier/golib v0.7.0
|
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/hashicorp/yamux v0.1.1
|
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.36.3
|
github.com/onsi/gomega v1.36.3
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0
|
github.com/pelletier/go-toml/v2 v2.2.0
|
||||||
github.com/pion/stun/v3 v3.1.1
|
github.com/pires/go-proxyproto v0.15.0
|
||||||
github.com/pires/go-proxyproto v0.7.0
|
|
||||||
github.com/prometheus/client_golang v1.19.1
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/quic-go/quic-go v0.55.0
|
github.com/prometheus/client_model v0.5.0
|
||||||
|
github.com/prometheus/common v0.48.0
|
||||||
|
github.com/quic-go/quic-go v0.60.0
|
||||||
github.com/rodaine/table v1.2.0
|
github.com/rodaine/table v1.2.0
|
||||||
github.com/samber/lo v1.47.0
|
github.com/samber/lo v1.47.0
|
||||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||||
@@ -28,11 +27,11 @@ require (
|
|||||||
github.com/tidwall/gjson v1.17.1
|
github.com/tidwall/gjson v1.17.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.56.0
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/time v0.10.0
|
golang.org/x/time v0.10.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
@@ -42,17 +41,11 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // 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/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-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
@@ -61,32 +54,18 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/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/cpu v0.1.1 // indirect
|
||||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // 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
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/tools v0.48.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
@@ -94,6 +73,3 @@ require (
|
|||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
|
||||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
|
||||||
|
|||||||
@@ -1,32 +1,18 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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/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 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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/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/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.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -34,14 +20,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
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/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.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
|
||||||
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
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 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
@@ -86,31 +70,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
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.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 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
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 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
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 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
|
||||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
|
||||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
|
||||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
|
||||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
|
||||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
|
||||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
|
||||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -126,11 +95,12 @@ 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/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 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
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/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
|
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||||
|
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.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 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
|
||||||
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
|
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=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
@@ -170,10 +140,6 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
|||||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/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 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
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 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
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=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||||
@@ -185,48 +151,43 @@ 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-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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
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-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-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-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-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-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.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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
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-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-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.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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -234,8 +195,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.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 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
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
|
mkdir -p ./release/packages
|
||||||
|
|
||||||
os_all='linux windows darwin freebsd openbsd android'
|
os_all='linux windows darwin freebsd openbsd android'
|
||||||
arch_all='amd64 arm arm64'
|
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
|
||||||
extra_all='_ hf'
|
extra_all='_ hf'
|
||||||
|
|
||||||
cd ./release
|
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().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().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.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
|
||||||
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
|
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
|
||||||
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
||||||
}
|
}
|
||||||
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ package legacy
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/types"
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -49,7 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
|
|||||||
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
|
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
|
||||||
out.Transport.ProxyURL = conf.HTTPProxy
|
out.Transport.ProxyURL = conf.HTTPProxy
|
||||||
out.Transport.PoolCount = conf.PoolCount
|
out.Transport.PoolCount = conf.PoolCount
|
||||||
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
|
out.Transport.TCPMux = new(conf.TCPMux)
|
||||||
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
||||||
out.Transport.Protocol = conf.Protocol
|
out.Transport.Protocol = conf.Protocol
|
||||||
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
|
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
|
||||||
@@ -57,8 +55,8 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
|
|||||||
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
|
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
|
||||||
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
|
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
|
||||||
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
|
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
|
||||||
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
|
out.Transport.TLS.Enable = new(conf.TLSEnable)
|
||||||
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
|
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
|
||||||
out.Transport.TLS.CertFile = conf.TLSCertFile
|
out.Transport.TLS.CertFile = conf.TLSCertFile
|
||||||
out.Transport.TLS.KeyFile = conf.TLSKeyFile
|
out.Transport.TLS.KeyFile = conf.TLSKeyFile
|
||||||
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
|
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
|
||||||
@@ -77,7 +75,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
|
|||||||
out.WebServer.PprofEnable = conf.PprofEnable
|
out.WebServer.PprofEnable = conf.PprofEnable
|
||||||
|
|
||||||
out.DNSServer = conf.DNSServer
|
out.DNSServer = conf.DNSServer
|
||||||
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
|
out.LoginFailExit = new(conf.LoginFailExit)
|
||||||
out.Start = conf.Start
|
out.Start = conf.Start
|
||||||
out.UDPPacketSize = conf.UDPPacketSize
|
out.UDPPacketSize = conf.UDPPacketSize
|
||||||
out.Metadatas = conf.Metas
|
out.Metadatas = conf.Metas
|
||||||
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
|
|||||||
out.Log.MaxDays = conf.LogMaxDays
|
out.Log.MaxDays = conf.LogMaxDays
|
||||||
out.Log.DisablePrintColor = conf.DisableLogColor
|
out.Log.DisablePrintColor = conf.DisableLogColor
|
||||||
|
|
||||||
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
|
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
|
||||||
out.SubDomainHost = conf.SubDomainHost
|
out.SubDomainHost = conf.SubDomainHost
|
||||||
out.Custom404Page = conf.Custom404Page
|
out.Custom404Page = conf.Custom404Page
|
||||||
out.UserConnTimeout = conf.UserConnTimeout
|
out.UserConnTimeout = conf.UserConnTimeout
|
||||||
out.UDPPacketSize = conf.UDPPacketSize
|
out.UDPPacketSize = conf.UDPPacketSize
|
||||||
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
|
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
|
||||||
|
|
||||||
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
|
out.Transport.TCPMux = new(conf.TCPMux)
|
||||||
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
||||||
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
|
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
|
||||||
out.Transport.MaxPoolCount = conf.MaxPoolCount
|
out.Transport.MaxPoolCount = conf.MaxPoolCount
|
||||||
|
|||||||
@@ -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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
|
|||||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
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 {
|
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||||
proxyCfgs := proxies
|
proxyCfgs := proxies
|
||||||
for _, c := range proxyCfgs {
|
for _, c := range proxyCfgs {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
|||||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
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
|
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||||
func TestYAMLEdgeCases(t *testing.T) {
|
func TestYAMLEdgeCases(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ type ClientCommonConfig struct {
|
|||||||
// STUN server to help penetrate NAT hole.
|
// STUN server to help penetrate NAT hole.
|
||||||
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
||||||
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
||||||
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
|
// is "", the default DNS will be used.
|
||||||
// supported, e.g. "https://1.1.1.1/dns-query".
|
|
||||||
DNSServer string `json:"dnsServer,omitempty"`
|
DNSServer string `json:"dnsServer,omitempty"`
|
||||||
// LoginFailExit controls whether or not the client should exit after a
|
// LoginFailExit controls whether or not the client should exit after a
|
||||||
// failed login attempt. If false, the client will retry until a login
|
// failed login attempt. If false, the client will retry until a login
|
||||||
@@ -63,6 +62,7 @@ type ClientCommonConfig struct {
|
|||||||
|
|
||||||
Log LogConfig `json:"log,omitempty"`
|
Log LogConfig `json:"log,omitempty"`
|
||||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||||
|
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
|
||||||
Transport ClientTransportConfig `json:"transport,omitempty"`
|
Transport ClientTransportConfig `json:"transport,omitempty"`
|
||||||
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
|
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ type ClientCommonConfig struct {
|
|||||||
func (c *ClientCommonConfig) Complete() error {
|
func (c *ClientCommonConfig) Complete() error {
|
||||||
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
|
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
|
||||||
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
|
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
|
||||||
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))
|
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, new(true))
|
||||||
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
|
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
|
||||||
|
|
||||||
if err := c.Auth.Complete(); err != nil {
|
if err := c.Auth.Complete(); err != nil {
|
||||||
@@ -95,6 +95,7 @@ func (c *ClientCommonConfig) Complete() error {
|
|||||||
c.Log.Complete()
|
c.Log.Complete()
|
||||||
c.Transport.Complete()
|
c.Transport.Complete()
|
||||||
c.WebServer.Complete()
|
c.WebServer.Complete()
|
||||||
|
c.MetricsServer.Complete()
|
||||||
|
|
||||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||||
return nil
|
return nil
|
||||||
@@ -152,7 +153,7 @@ func (c *ClientTransportConfig) Complete() {
|
|||||||
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
||||||
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
||||||
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
|
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
|
||||||
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
|
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
|
||||||
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
||||||
if lo.FromPtr(c.TCPMux) {
|
if lo.FromPtr(c.TCPMux) {
|
||||||
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.
|
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.
|
||||||
@@ -181,8 +182,8 @@ type TLSClientConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *TLSClientConfig) Complete() {
|
func (c *TLSClientConfig) Complete() {
|
||||||
c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))
|
c.Enable = util.EmptyOr(c.Enable, new(true))
|
||||||
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))
|
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, new(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthClientConfig struct {
|
type AuthClientConfig struct {
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ func (c *WebServerConfig) Complete() {
|
|||||||
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
|
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetricsServerConfig configures a separate listener for health checks, metrics
|
||||||
|
// (frps only), and optional pprof handlers. It does not serve the web interface.
|
||||||
|
type MetricsServerConfig struct {
|
||||||
|
// Addr defaults to "127.0.0.1".
|
||||||
|
Addr string `json:"addr,omitempty"`
|
||||||
|
// Port disables the listener when set to 0.
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
// User and Password protect metrics and pprof; health checks remain anonymous.
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
// PprofEnable explicitly enables profiling, excluding cmdline.
|
||||||
|
PprofEnable bool `json:"pprofEnable,omitempty"`
|
||||||
|
// Enable TLS if TLS is not nil.
|
||||||
|
TLS *TLSConfig `json:"tls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MetricsServerConfig) Complete() {
|
||||||
|
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
type TLSConfig struct {
|
type TLSConfig struct {
|
||||||
// CertFile specifies the path of the cert file that client will load.
|
// CertFile specifies the path of the cert file that client will load.
|
||||||
CertFile string `json:"certFile,omitempty"`
|
CertFile string `json:"certFile,omitempty"`
|
||||||
@@ -79,6 +99,10 @@ type TLSConfig struct {
|
|||||||
KeyFile string `json:"keyFile,omitempty"`
|
KeyFile string `json:"keyFile,omitempty"`
|
||||||
// TrustedCaFile specifies the path of the trusted ca file that will load.
|
// TrustedCaFile specifies the path of the trusted ca file that will load.
|
||||||
TrustedCaFile string `json:"trustedCaFile,omitempty"`
|
TrustedCaFile string `json:"trustedCaFile,omitempty"`
|
||||||
|
// UseSystemCa enables server certificate verification using system CAs.
|
||||||
|
// If TrustedCaFile is also set, its certificates are added to the system CA pool.
|
||||||
|
// Only supported in frpc's transport.tls. With neither trust source, frpc skips verification.
|
||||||
|
UseSystemCa bool `json:"useSystemCa,omitempty"`
|
||||||
// ServerName specifies the custom server name of tls certificate. By
|
// ServerName specifies the custom server name of tls certificate. By
|
||||||
// default, server name if same to ServerAddr.
|
// default, server name if same to ServerAddr.
|
||||||
ServerName string `json:"serverName,omitempty"`
|
ServerName string `json:"serverName,omitempty"`
|
||||||
|
|||||||
@@ -369,12 +369,6 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
|
|||||||
type HTTPSProxyConfig struct {
|
type HTTPSProxyConfig struct {
|
||||||
ProxyBaseConfig
|
ProxyBaseConfig
|
||||||
DomainConfig
|
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) {
|
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||||
@@ -382,7 +376,6 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
|||||||
|
|
||||||
m.CustomDomains = c.CustomDomains
|
m.CustomDomains = c.CustomDomains
|
||||||
m.SubDomain = c.SubDomain
|
m.SubDomain = c.SubDomain
|
||||||
m.HTTPRedirect = c.HTTPRedirect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||||
@@ -390,7 +383,6 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
|||||||
|
|
||||||
c.CustomDomains = m.CustomDomains
|
c.CustomDomains = m.CustomDomains
|
||||||
c.SubDomain = m.SubDomain
|
c.SubDomain = m.SubDomain
|
||||||
c.HTTPRedirect = m.HTTPRedirect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/jsonx"
|
"github.com/fatedier/frp/pkg/util/jsonx"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
@@ -26,7 +23,6 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
PluginHTTP2HTTPS = "http2https"
|
PluginHTTP2HTTPS = "http2https"
|
||||||
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
|
||||||
PluginHTTPProxy = "http_proxy"
|
PluginHTTPProxy = "http_proxy"
|
||||||
PluginHTTPS2HTTP = "https2http"
|
PluginHTTPS2HTTP = "https2http"
|
||||||
PluginHTTPS2HTTPS = "https2https"
|
PluginHTTPS2HTTPS = "https2https"
|
||||||
@@ -40,7 +36,6 @@ const (
|
|||||||
|
|
||||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||||
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
|
|
||||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||||
@@ -83,29 +78,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
|
|||||||
return jsonx.Marshal(c.ClientPluginOptions)
|
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 HTTP2HTTPSPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
LocalAddr string `json:"localAddr,omitempty"`
|
LocalAddr string `json:"localAddr,omitempty"`
|
||||||
@@ -124,21 +96,6 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
|||||||
return &out
|
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 HTTPProxyPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
HTTPUser string `json:"httpUser,omitempty"`
|
HTTPUser string `json:"httpUser,omitempty"`
|
||||||
@@ -163,11 +120,10 @@ type HTTPS2HTTPPluginOptions struct {
|
|||||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||||
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
|
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
||||||
@@ -177,7 +133,6 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
|||||||
out := *o
|
out := *o
|
||||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +144,10 @@ type HTTPS2HTTPSPluginOptions struct {
|
|||||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||||
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
|
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||||
@@ -203,7 +157,6 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
|||||||
out := *o
|
out := *o
|
||||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +232,6 @@ type TLS2RawPluginOptions struct {
|
|||||||
LocalAddr string `json:"localAddr,omitempty"`
|
LocalAddr string `json:"localAddr,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *TLS2RawPluginOptions) Complete() {}
|
func (o *TLS2RawPluginOptions) Complete() {}
|
||||||
@@ -289,7 +241,6 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := *o
|
out := *o
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-16
@@ -51,12 +51,6 @@ type ServerConfig struct {
|
|||||||
// Vhost requests. If this value is 0, the server will not listen for HTTPS
|
// Vhost requests. If this value is 0, the server will not listen for HTTPS
|
||||||
// requests.
|
// requests.
|
||||||
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
|
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
|
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
|
||||||
// HTTP CONNECT requests. If the value is 0, the server will not multiplex 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
|
// requests on one single port. If it's not - it will listen on this value for
|
||||||
@@ -76,6 +70,7 @@ type ServerConfig struct {
|
|||||||
SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`
|
SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`
|
||||||
|
|
||||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||||
|
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
|
||||||
// EnablePrometheus will export prometheus metrics on webserver address
|
// EnablePrometheus will export prometheus metrics on webserver address
|
||||||
// in /metrics api.
|
// in /metrics api.
|
||||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||||
@@ -111,6 +106,7 @@ func (c *ServerConfig) Complete() error {
|
|||||||
c.Log.Complete()
|
c.Log.Complete()
|
||||||
c.Transport.Complete()
|
c.Transport.Complete()
|
||||||
c.WebServer.Complete()
|
c.WebServer.Complete()
|
||||||
|
c.MetricsServer.Complete()
|
||||||
c.SSHTunnelGateway.Complete()
|
c.SSHTunnelGateway.Complete()
|
||||||
|
|
||||||
c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0")
|
c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0")
|
||||||
@@ -124,8 +120,7 @@ func (c *ServerConfig) Complete() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
||||||
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
|
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, new(true))
|
||||||
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
|
|
||||||
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
||||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||||
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
|
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
|
||||||
@@ -174,18 +169,12 @@ type ServerTransportConfig struct {
|
|||||||
// If negative, keep-alive probes are disabled.
|
// If negative, keep-alive probes are disabled.
|
||||||
TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"`
|
TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"`
|
||||||
// MaxPoolCount specifies the maximum pool size for each proxy. By default,
|
// 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"`
|
MaxPoolCount int64 `json:"maxPoolCount,omitempty"`
|
||||||
// HeartBeatTimeout specifies the maximum time to wait for a heartbeat
|
// HeartBeatTimeout specifies the maximum time to wait for a heartbeat
|
||||||
// before terminating the connection. It is not recommended to change this
|
// before terminating the connection. It is not recommended to change this
|
||||||
// value. By default, this value is 90. Set negative value to disable it.
|
// value. By default, this value is 90. Set negative value to disable it.
|
||||||
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
|
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 options.
|
||||||
QUIC QUICOptions `json:"quic,omitempty"`
|
QUIC QUICOptions `json:"quic,omitempty"`
|
||||||
// TLS specifies TLS settings for the connection from the client.
|
// TLS specifies TLS settings for the connection from the client.
|
||||||
@@ -193,7 +182,7 @@ type ServerTransportConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ServerTransportConfig) Complete() {
|
func (c *ServerTransportConfig) Complete() {
|
||||||
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
|
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
|
||||||
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
||||||
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
|
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
|
||||||
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
|
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
|
|||||||
func() (Warning, error) { return v.validateAuthConfig(&c.Auth) },
|
func() (Warning, error) { return v.validateAuthConfig(&c.Auth) },
|
||||||
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
|
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
|
||||||
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
|
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
|
||||||
|
func() (Warning, error) { return nil, validateMetricsServerConfig(&c.MetricsServer, &c.WebServer) },
|
||||||
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
|
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
|
||||||
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
|
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
|
||||||
}
|
}
|
||||||
@@ -51,14 +52,51 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
|
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 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, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, nil
|
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) {
|
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
|
||||||
var errs error
|
var errs error
|
||||||
if !slices.Contains(SupportedAuthMethods, c.Method) {
|
if !slices.Contains(SupportedAuthMethods, c.Method) {
|
||||||
@@ -126,6 +164,10 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
|
|||||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile))
|
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile))
|
||||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
|
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
|
||||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
|
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
|
||||||
|
// wss keeps TLS on regardless of transport.tls.enable.
|
||||||
|
if c.TLS.UseSystemCa && c.Protocol != "wss" {
|
||||||
|
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa requires transport.tls.enable to be true unless transport.protocol is wss"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
|
|
||||||
func validateWebServerConfig(c *v1.WebServerConfig) error {
|
func validateWebServerConfig(c *v1.WebServerConfig) error {
|
||||||
if c.TLS != nil {
|
if c.TLS != nil {
|
||||||
|
if c.TLS.UseSystemCa {
|
||||||
|
return fmt.Errorf("webServer.tls.useSystemCa is only supported in frpc transport.tls")
|
||||||
|
}
|
||||||
if c.TLS.CertFile == "" {
|
if c.TLS.CertFile == "" {
|
||||||
return fmt.Errorf("tls.certFile must be specified when tls is enabled")
|
return fmt.Errorf("tls.certFile must be specified when tls is enabled")
|
||||||
}
|
}
|
||||||
@@ -34,6 +37,27 @@ func validateWebServerConfig(c *v1.WebServerConfig) error {
|
|||||||
return ValidatePort(c.Port, "webServer.port")
|
return ValidatePort(c.Port, "webServer.port")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateMetricsServerConfig(c *v1.MetricsServerConfig, web *v1.WebServerConfig) error {
|
||||||
|
if err := ValidatePort(c.Port, "metricsServer.port"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.TLS != nil {
|
||||||
|
if c.TLS.UseSystemCa {
|
||||||
|
return fmt.Errorf("metricsServer.tls.useSystemCa is only supported in frpc transport.tls")
|
||||||
|
}
|
||||||
|
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
|
||||||
|
return fmt.Errorf("metricsServer.tls.certFile and metricsServer.tls.keyFile must both be specified when tls is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only reject an exact duplicate. Whether two different addresses can share
|
||||||
|
// a port depends on the address family and the platform, so leave it to
|
||||||
|
// net.Listen to decide.
|
||||||
|
if c.Port > 0 && web.Port > 0 && c.Port == web.Port && c.Addr == web.Addr {
|
||||||
|
return fmt.Errorf("metricsServer.addr:port conflicts with webServer.addr:port")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ValidatePort checks that the network port is in range
|
// ValidatePort checks that the network port is in range
|
||||||
func ValidatePort(port int, fieldPath string) error {
|
func ValidatePort(port int, fieldPath string) error {
|
||||||
if 0 <= port && port <= 65535 {
|
if 0 <= port && port <= 65535 {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateMetricsServerConfig(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
metrics v1.MetricsServerConfig
|
||||||
|
web v1.WebServerConfig
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "disabled"},
|
||||||
|
{name: "negative port", metrics: v1.MetricsServerConfig{Port: -1}, wantErr: "metricsServer.port"},
|
||||||
|
{name: "overflow port", metrics: v1.MetricsServerConfig{Port: 65536}, wantErr: "metricsServer.port"},
|
||||||
|
{name: "maximum port", metrics: v1.MetricsServerConfig{Port: 65535}},
|
||||||
|
{name: "empty TLS", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{}}, wantErr: "metricsServer.tls.certFile"},
|
||||||
|
{name: "only certificate", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert"}}, wantErr: "metricsServer.tls.keyFile"},
|
||||||
|
{name: "only key", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{KeyFile: "key"}}, wantErr: "metricsServer.tls.certFile"},
|
||||||
|
{name: "TLS pair", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert", KeyFile: "key"}}},
|
||||||
|
{name: "system CA", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{UseSystemCa: true}}, wantErr: "metricsServer.tls.useSystemCa"},
|
||||||
|
{
|
||||||
|
name: "same address", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
|
||||||
|
web: v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, wantErr: "conflicts with webServer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different addresses", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
|
||||||
|
web: v1.WebServerConfig{Addr: "127.0.0.2", Port: 7501},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different ports", metrics: v1.MetricsServerConfig{Port: 7501},
|
||||||
|
web: v1.WebServerConfig{Port: 7500},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "web disabled", metrics: v1.MetricsServerConfig{Port: 7501},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics disabled", web: v1.WebServerConfig{Port: 7501},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
checkMetricsConfigBoth(t, tc.metrics, tc.web, tc.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Two different addresses on the same port may or may not coexist depending
|
||||||
|
// on the address family and the platform, so only an exact duplicate is a
|
||||||
|
// configuration error here.
|
||||||
|
for _, wildcard := range []string{"", "0.0.0.0", "::"} {
|
||||||
|
t.Run("wildcard "+wildcard+" with loopback", func(t *testing.T) {
|
||||||
|
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
|
||||||
|
v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, "")
|
||||||
|
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
|
||||||
|
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "")
|
||||||
|
})
|
||||||
|
t.Run("same wildcard "+wildcard, func(t *testing.T) {
|
||||||
|
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
|
||||||
|
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "conflicts with webServer")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("different wildcards on the same port", func(t *testing.T) {
|
||||||
|
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "0.0.0.0", Port: 7501},
|
||||||
|
v1.WebServerConfig{Addr: "::", Port: 7501}, "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMetricsConfigBoth(t *testing.T, metrics v1.MetricsServerConfig, web v1.WebServerConfig, wantErr string) {
|
||||||
|
t.Helper()
|
||||||
|
server := &v1.ServerConfig{}
|
||||||
|
client := &v1.ClientCommonConfig{}
|
||||||
|
require.NoError(t, server.Complete())
|
||||||
|
require.NoError(t, client.Complete())
|
||||||
|
// Assign after completion to also check raw empty (wildcard) addresses.
|
||||||
|
server.MetricsServer, server.WebServer = metrics, web
|
||||||
|
client.MetricsServer, client.WebServer = metrics, web
|
||||||
|
validator := NewConfigValidator(nil)
|
||||||
|
_, serverErr := validator.ValidateServerConfig(server)
|
||||||
|
_, clientErr := validator.ValidateClientCommonConfig(client)
|
||||||
|
for _, err := range []error{serverErr, clientErr} {
|
||||||
|
if wantErr == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.ErrorContains(t, err, wantErr)
|
||||||
|
require.ErrorContains(t, err, "metricsServer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsServerDefaults(t *testing.T) {
|
||||||
|
for _, port := range []int{0, 7501} {
|
||||||
|
server := &v1.ServerConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
|
||||||
|
client := &v1.ClientCommonConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
|
||||||
|
require.NoError(t, server.Complete())
|
||||||
|
require.NoError(t, client.Complete())
|
||||||
|
for _, cfg := range []v1.MetricsServerConfig{server.MetricsServer, client.MetricsServer} {
|
||||||
|
require.Equal(t, "127.0.0.1", cfg.Addr)
|
||||||
|
require.Equal(t, port, cfg.Port)
|
||||||
|
require.False(t, cfg.PprofEnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxRunIDLength is the maximum number of bytes accepted for a control run ID.
|
||||||
|
MaxRunIDLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateIdentifier(value, kind string, maxLength int) error {
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("%s cannot be empty", kind)
|
||||||
|
}
|
||||||
|
if len(value) > maxLength {
|
||||||
|
return fmt.Errorf("%s is too long: length %d exceeds maximum %d", kind, len(value), maxLength)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(value) {
|
||||||
|
return fmt.Errorf("%s must be valid UTF-8", kind)
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if !unicode.IsPrint(r) {
|
||||||
|
return fmt.Errorf("%s contains non-printable character", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateRunID(runID string) error {
|
||||||
|
return validateIdentifier(runID, "run id", MaxRunIDLength)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateIdentifiers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
validate func(string) error
|
||||||
|
value string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{name: "run id accepts printable values", validate: ValidateRunID, value: "run-%1000s-中文"},
|
||||||
|
{name: "run id rejects empty", validate: ValidateRunID, wantError: "cannot be empty"},
|
||||||
|
{name: "run id rejects control character", validate: ValidateRunID, value: "run\nforged", wantError: "non-printable"},
|
||||||
|
{name: "run id rejects invalid utf8", validate: ValidateRunID, value: string([]byte{0xff}), wantError: "valid UTF-8"},
|
||||||
|
{name: "run id rejects excessive length", validate: ValidateRunID, value: strings.Repeat("a", MaxRunIDLength+1), wantError: "too long"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.validate(tt.value)
|
||||||
|
if tt.wantError == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.ErrorContains(t, err, tt.wantError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,6 @@ package validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -26,8 +24,6 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
|
|||||||
switch v := c.(type) {
|
switch v := c.(type) {
|
||||||
case *v1.HTTP2HTTPSPluginOptions:
|
case *v1.HTTP2HTTPSPluginOptions:
|
||||||
return validateHTTP2HTTPSPluginOptions(v)
|
return validateHTTP2HTTPSPluginOptions(v)
|
||||||
case *v1.HTTP2HTTPSRedirectPluginOptions:
|
|
||||||
return validateHTTP2HTTPSRedirectPluginOptions(v)
|
|
||||||
case *v1.HTTPS2HTTPPluginOptions:
|
case *v1.HTTPS2HTTPPluginOptions:
|
||||||
return validateHTTPS2HTTPPluginOptions(v)
|
return validateHTTPS2HTTPPluginOptions(v)
|
||||||
case *v1.HTTPS2HTTPSPluginOptions:
|
case *v1.HTTPS2HTTPSPluginOptions:
|
||||||
@@ -49,17 +45,10 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
|
|
||||||
return ValidatePort(c.HTTPSPort, "httpsPort")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +56,6 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,29 +77,5 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
|
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
|
||||||
|
subDomainHost := strings.ToLower(s.SubDomainHost)
|
||||||
for _, domain := range c.CustomDomains {
|
for _, domain := range c.CustomDomains {
|
||||||
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
|
canonicalDomain := strings.ToLower(domain)
|
||||||
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
|
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)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
|||||||
if err := validateWebServerConfig(&c.WebServer); err != nil {
|
if err := validateWebServerConfig(&c.WebServer); err != nil {
|
||||||
errs = AppendError(errs, err)
|
errs = AppendError(errs, err)
|
||||||
}
|
}
|
||||||
|
if err := validateMetricsServerConfig(&c.MetricsServer, &c.WebServer); err != nil {
|
||||||
|
errs = AppendError(errs, err)
|
||||||
|
}
|
||||||
|
if c.Transport.TLS.UseSystemCa {
|
||||||
|
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa is only supported in frpc transport.tls"))
|
||||||
|
}
|
||||||
|
|
||||||
errs = AppendError(errs, ValidatePort(c.BindPort, "bindPort"))
|
errs = AppendError(errs, ValidatePort(c.BindPort, "bindPort"))
|
||||||
errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort"))
|
errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort"))
|
||||||
@@ -51,6 +57,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
|||||||
errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort"))
|
errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort"))
|
||||||
errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort"))
|
errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort"))
|
||||||
errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort"))
|
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 {
|
for _, p := range c.HTTPPlugins {
|
||||||
if !lo.Every(SupportedHTTPPluginOps, p.Ops) {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientTLSCATrustSources(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
tlsJSON string
|
||||||
|
useSystemCa bool
|
||||||
|
caFile string
|
||||||
|
}{
|
||||||
|
{name: "default", tlsJSON: `{}`},
|
||||||
|
{name: "file only", tlsJSON: `{"trustedCaFile":"ca.pem"}`, caFile: "ca.pem"},
|
||||||
|
{name: "system only", tlsJSON: `{"useSystemCa":true}`, useSystemCa: true},
|
||||||
|
{name: "combined", tlsJSON: `{"useSystemCa":true,"trustedCaFile":"ca.pem"}`, useSystemCa: true, caFile: "ca.pem"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := &v1.ClientCommonConfig{}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(`{"transport":{"tls":`+test.tlsJSON+`}}`), cfg))
|
||||||
|
require.NoError(t, cfg.Complete())
|
||||||
|
require.Equal(t, test.useSystemCa, cfg.Transport.TLS.UseSystemCa)
|
||||||
|
require.Equal(t, test.caFile, cfg.Transport.TLS.TrustedCaFile)
|
||||||
|
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||||
|
require.NoError(t, warnings)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientSystemCARequiresTLS(t *testing.T) {
|
||||||
|
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
|
||||||
|
for _, state := range []struct {
|
||||||
|
name string
|
||||||
|
enable *bool
|
||||||
|
}{
|
||||||
|
{name: "default"},
|
||||||
|
{name: "enabled", enable: new(true)},
|
||||||
|
{name: "disabled", enable: new(false)},
|
||||||
|
} {
|
||||||
|
t.Run(protocol+"/"+state.name, func(t *testing.T) {
|
||||||
|
cfg := &v1.ClientCommonConfig{}
|
||||||
|
cfg.Transport.Protocol = protocol
|
||||||
|
cfg.Transport.TLS.Enable = state.enable
|
||||||
|
cfg.Transport.TLS.UseSystemCa = true
|
||||||
|
require.NoError(t, cfg.Complete())
|
||||||
|
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||||
|
require.NoError(t, warnings, "useSystemCa must produce an error, not a warning")
|
||||||
|
if state.name == "disabled" && protocol != "wss" {
|
||||||
|
require.ErrorContains(t, err, "transport.tls.useSystemCa")
|
||||||
|
require.ErrorContains(t, err, "transport.tls.enable")
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledTLSFileWarnings(t *testing.T) {
|
||||||
|
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
|
||||||
|
t.Run(protocol, func(t *testing.T) {
|
||||||
|
cfg := &v1.ClientCommonConfig{}
|
||||||
|
cfg.Transport.Protocol = protocol
|
||||||
|
cfg.Transport.TLS.Enable = new(false)
|
||||||
|
cfg.Transport.TLS.CertFile = "client.pem"
|
||||||
|
cfg.Transport.TLS.KeyFile = "client.key"
|
||||||
|
cfg.Transport.TLS.TrustedCaFile = "ca.pem"
|
||||||
|
require.NoError(t, cfg.Complete())
|
||||||
|
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, field := range []string{"certFile", "keyFile", "trustedCaFile"} {
|
||||||
|
require.ErrorContains(t, warnings, "transport.tls."+field+" is invalid when transport.tls.enable is false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemCAUnsupportedLocations(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
server bool
|
||||||
|
configJSON string
|
||||||
|
fieldPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "frps transport", server: true,
|
||||||
|
configJSON: `{"transport":{"tls":{"useSystemCa":%t}}}`, fieldPath: "transport.tls.useSystemCa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "frpc webServer", configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
|
||||||
|
fieldPath: "webServer.tls.useSystemCa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "frps webServer", server: true,
|
||||||
|
configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
|
||||||
|
fieldPath: "webServer.tls.useSystemCa",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
for _, enabled := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%t", test.name, enabled), func(t *testing.T) {
|
||||||
|
data := fmt.Appendf(nil, test.configJSON, enabled)
|
||||||
|
var warnings Warning
|
||||||
|
var err error
|
||||||
|
if test.server {
|
||||||
|
cfg := &v1.ServerConfig{}
|
||||||
|
require.NoError(t, json.Unmarshal(data, cfg))
|
||||||
|
require.NoError(t, cfg.Complete())
|
||||||
|
warnings, err = NewConfigValidator(nil).ValidateServerConfig(cfg)
|
||||||
|
} else {
|
||||||
|
cfg := &v1.ClientCommonConfig{}
|
||||||
|
require.NoError(t, json.Unmarshal(data, cfg))
|
||||||
|
require.NoError(t, cfg.Complete())
|
||||||
|
warnings, err = NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||||
|
}
|
||||||
|
require.NoError(t, warnings)
|
||||||
|
if enabled {
|
||||||
|
require.EqualError(t, err, test.fieldPath+" is only supported in frpc transport.tls")
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-11
@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
total = len(m.info.ProxyStatistics)
|
total = len(m.info.ProxyStatistics)
|
||||||
for name, data := range m.info.ProxyStatistics {
|
for name, data := range m.info.ProxyStatistics {
|
||||||
if !data.LastCloseTime.IsZero() &&
|
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
|
||||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
|
||||||
delete(m.info.ProxyStatistics, name)
|
delete(m.info.ProxyStatistics, name)
|
||||||
count++
|
count++
|
||||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
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
|
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) {
|
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||||
return m.clearUselessInfo(0)
|
return m.clearUselessInfo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||||
|
return m.clearUselessInfo(0)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewClient() {
|
func (m *serverMetrics) NewClient() {
|
||||||
m.info.ClientCounts.Inc(1)
|
m.info.ClientCounts.Inc(1)
|
||||||
}
|
}
|
||||||
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
|||||||
}
|
}
|
||||||
if !proxyStats.LastStartTime.IsZero() {
|
if !proxyStats.LastStartTime.IsZero() {
|
||||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||||
}
|
}
|
||||||
if !proxyStats.LastCloseTime.IsZero() {
|
if !proxyStats.LastCloseTime.IsZero() {
|
||||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||||
}
|
}
|
||||||
return ps
|
return ps
|
||||||
}
|
}
|
||||||
@@ -243,9 +253,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
filterAll := proxyType == "" || proxyType == "all"
|
|
||||||
for name, proxyStats := range m.info.ProxyStatistics {
|
for name, proxyStats := range m.info.ProxyStatistics {
|
||||||
if !filterAll && proxyStats.ProxyType != proxyType {
|
if proxyStats.ProxyType != proxyType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res = append(res, toProxyStats(name, proxyStats))
|
res = append(res, toProxyStats(name, proxyStats))
|
||||||
@@ -258,14 +267,9 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||||
if ok {
|
if ok && proxyStats.ProxyType == proxyType {
|
||||||
// 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)
|
res = toProxyStats(proxyName, proxyStats)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
|||||||
clk.SetTime(closedAt)
|
clk.SetTime(closedAt)
|
||||||
metrics.CloseProxy("proxy", "tcp")
|
metrics.CloseProxy("proxy", "tcp")
|
||||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
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) {
|
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||||
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
|||||||
require.Empty(metrics.info.ProxyStatistics)
|
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) {
|
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
|||||||
TodayTrafficOut int64
|
TodayTrafficOut int64
|
||||||
LastStartTime string
|
LastStartTime string
|
||||||
LastCloseTime string
|
LastCloseTime string
|
||||||
|
LastStartAt int64
|
||||||
|
LastCloseAt int64
|
||||||
CurConns int64
|
CurConns int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +87,5 @@ type Collector interface {
|
|||||||
GetProxyByName(proxyName string) *ProxyStats
|
GetProxyByName(proxyName string) *ProxyStats
|
||||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||||
ClearOfflineProxies() (int, int)
|
ClearOfflineProxies() (int, int)
|
||||||
|
PruneOfflineProxies() (int, int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// 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 text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collector returns the families of one scrape.
|
||||||
|
type collector func() []family
|
||||||
|
|
||||||
|
// familiesPerCollector sizes the gather buffer; the runtime collector is the
|
||||||
|
// only one today and emits the 22 memstats families plus a handful of others.
|
||||||
|
const familiesPerCollector = 32
|
||||||
|
|
||||||
|
// Registry holds the collectors exposed on one listener.
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
collectors []collector
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry returns a registry with no collectors.
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRuntime registers the Go runtime collector.
|
||||||
|
func (r *Registry) RegisterRuntime() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.collectors = append(r.collectors, runtimeFamilies)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePrometheus writes every collected family in the text exposition format.
|
||||||
|
func (r *Registry) WritePrometheus(w io.Writer) error {
|
||||||
|
return encode(w, r.gather())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns an http.Handler serving the registry.
|
||||||
|
func (r *Registry) Handler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// Encode into memory first so that a failure never sends a truncated body.
|
||||||
|
var b strings.Builder
|
||||||
|
if err := r.WritePrometheus(&b); err != nil {
|
||||||
|
http.Error(w, "metrics unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ContentType)
|
||||||
|
_, _ = io.WriteString(w, b.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) gather() []family {
|
||||||
|
r.mu.RLock()
|
||||||
|
collectors := append([]collector(nil), r.collectors...)
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]family, 0, len(collectors)*familiesPerCollector)
|
||||||
|
for _, c := range collectors {
|
||||||
|
out = append(out, c()...)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
// 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 text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"runtime/metrics"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memstats is one go_memstats_* family. The values are read the same way
|
||||||
|
// client_golang reads them since Go 1.17: from runtime/metrics, which does not
|
||||||
|
// stop the world, and then mapped onto runtime.MemStats fields.
|
||||||
|
type memstats struct {
|
||||||
|
name string
|
||||||
|
help string
|
||||||
|
typ metricType
|
||||||
|
get func(*runtime.MemStats) float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// memstatsFamilies matches the go_memstats_* families of client_golang's default
|
||||||
|
// registry, including their help strings and types.
|
||||||
|
var memstatsFamilies = []memstats{
|
||||||
|
{
|
||||||
|
name: "go_memstats_alloc_bytes",
|
||||||
|
help: "Number of bytes allocated and still in use.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_alloc_bytes_total",
|
||||||
|
help: "Total number of bytes allocated, even if freed.",
|
||||||
|
typ: typeCounter,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.TotalAlloc) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_buck_hash_sys_bytes",
|
||||||
|
help: "Number of bytes used by the profiling bucket hash table.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_frees_total",
|
||||||
|
help: "Total number of frees.",
|
||||||
|
typ: typeCounter,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.Frees) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_gc_sys_bytes",
|
||||||
|
help: "Number of bytes used for garbage collection system metadata.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.GCSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_alloc_bytes",
|
||||||
|
help: "Number of heap bytes allocated and still in use.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapAlloc) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_idle_bytes",
|
||||||
|
help: "Number of heap bytes waiting to be used.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapIdle) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_inuse_bytes",
|
||||||
|
help: "Number of heap bytes that are in use.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_objects",
|
||||||
|
help: "Number of allocated objects.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapObjects) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_released_bytes",
|
||||||
|
help: "Number of heap bytes released to OS.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapReleased) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_sys_bytes",
|
||||||
|
help: "Number of heap bytes obtained from system.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_lookups_total",
|
||||||
|
help: "Total number of pointer lookups.",
|
||||||
|
typ: typeCounter,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.Lookups) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_mallocs_total",
|
||||||
|
help: "Total number of mallocs.",
|
||||||
|
typ: typeCounter,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.Mallocs) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_mcache_inuse_bytes",
|
||||||
|
help: "Number of bytes in use by mcache structures.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheInuse) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_mcache_sys_bytes",
|
||||||
|
help: "Number of bytes used for mcache structures obtained from system.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_mspan_inuse_bytes",
|
||||||
|
help: "Number of bytes in use by mspan structures.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanInuse) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_mspan_sys_bytes",
|
||||||
|
help: "Number of bytes used for mspan structures obtained from system.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_next_gc_bytes",
|
||||||
|
help: "Number of heap bytes when next garbage collection will take place.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_other_sys_bytes",
|
||||||
|
help: "Number of bytes used for other system allocations.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_stack_inuse_bytes",
|
||||||
|
help: "Number of bytes in use by the stack allocator.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackInuse) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_stack_sys_bytes",
|
||||||
|
help: "Number of bytes obtained from system for stack allocator.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_sys_bytes",
|
||||||
|
help: "Number of bytes obtained from system.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.Sys) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runtime/metrics names below are the ones client_golang v1.19.1 reads to
|
||||||
|
// build the go_memstats_* families. See prometheus/go_collector_latest.go in
|
||||||
|
// that module (Apache-2.0).
|
||||||
|
const (
|
||||||
|
rmGCHeapTinyAllocsObjects = "/gc/heap/tiny/allocs:objects"
|
||||||
|
rmGCHeapAllocsObjects = "/gc/heap/allocs:objects"
|
||||||
|
rmGCHeapFreesObjects = "/gc/heap/frees:objects"
|
||||||
|
rmGCHeapAllocsBytes = "/gc/heap/allocs:bytes"
|
||||||
|
rmGCHeapObjects = "/gc/heap/objects:objects"
|
||||||
|
rmGCHeapGoalBytes = "/gc/heap/goal:bytes"
|
||||||
|
rmMemoryClassesTotalBytes = "/memory/classes/total:bytes"
|
||||||
|
rmMemoryClassesHeapObjectsBytes = "/memory/classes/heap/objects:bytes"
|
||||||
|
rmMemoryClassesHeapUnusedBytes = "/memory/classes/heap/unused:bytes"
|
||||||
|
rmMemoryClassesHeapReleasedBytes = "/memory/classes/heap/released:bytes"
|
||||||
|
rmMemoryClassesHeapFreeBytes = "/memory/classes/heap/free:bytes"
|
||||||
|
rmMemoryClassesHeapStacksBytes = "/memory/classes/heap/stacks:bytes"
|
||||||
|
rmMemoryClassesOSStacksBytes = "/memory/classes/os-stacks:bytes"
|
||||||
|
rmMemoryClassesMetadataMSpanInuseBytes = "/memory/classes/metadata/mspan/inuse:bytes"
|
||||||
|
rmMemoryClassesMetadataMSpanFreeBytes = "/memory/classes/metadata/mspan/free:bytes"
|
||||||
|
rmMemoryClassesMetadataMCacheInuseBytes = "/memory/classes/metadata/mcache/inuse:bytes"
|
||||||
|
rmMemoryClassesMetadataMCacheFreeBytes = "/memory/classes/metadata/mcache/free:bytes"
|
||||||
|
rmMemoryClassesProfilingBucketsBytes = "/memory/classes/profiling/buckets:bytes"
|
||||||
|
rmMemoryClassesMetadataOtherBytes = "/memory/classes/metadata/other:bytes"
|
||||||
|
rmMemoryClassesOtherBytes = "/memory/classes/other:bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memoryNames lists every runtime/metrics name memStatsFromRM reads.
|
||||||
|
var memoryNames = []string{
|
||||||
|
rmGCHeapTinyAllocsObjects,
|
||||||
|
rmGCHeapAllocsObjects,
|
||||||
|
rmGCHeapFreesObjects,
|
||||||
|
rmGCHeapAllocsBytes,
|
||||||
|
rmGCHeapObjects,
|
||||||
|
rmGCHeapGoalBytes,
|
||||||
|
rmMemoryClassesTotalBytes,
|
||||||
|
rmMemoryClassesHeapObjectsBytes,
|
||||||
|
rmMemoryClassesHeapUnusedBytes,
|
||||||
|
rmMemoryClassesHeapReleasedBytes,
|
||||||
|
rmMemoryClassesHeapFreeBytes,
|
||||||
|
rmMemoryClassesHeapStacksBytes,
|
||||||
|
rmMemoryClassesOSStacksBytes,
|
||||||
|
rmMemoryClassesMetadataMSpanInuseBytes,
|
||||||
|
rmMemoryClassesMetadataMSpanFreeBytes,
|
||||||
|
rmMemoryClassesMetadataMCacheInuseBytes,
|
||||||
|
rmMemoryClassesMetadataMCacheFreeBytes,
|
||||||
|
rmMemoryClassesProfilingBucketsBytes,
|
||||||
|
rmMemoryClassesMetadataOtherBytes,
|
||||||
|
rmMemoryClassesOtherBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
// memorySamples holds the sample buffer of one scrape and the index that maps a
|
||||||
|
// runtime/metrics name to its slot.
|
||||||
|
type memorySamples struct {
|
||||||
|
samples []metrics.Sample
|
||||||
|
index map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// memStatsSnapshot takes the Go memory statistics frpc reports. It reads the
|
||||||
|
// runtime/metrics package instead of calling runtime.ReadMemStats, which stops
|
||||||
|
// the world on every call, and memStatsFromRM maps the samples onto the old
|
||||||
|
// field names.
|
||||||
|
//
|
||||||
|
// The sample buffer is reused between scrapes, so concurrent scrapes take turns:
|
||||||
|
// runtime/metrics.Read writes into the samples it is handed.
|
||||||
|
func memStatsSnapshot() runtime.MemStats {
|
||||||
|
memoryMu.Lock()
|
||||||
|
defer memoryMu.Unlock()
|
||||||
|
|
||||||
|
if memoryBuffer == nil {
|
||||||
|
memoryBuffer = newMemorySamples()
|
||||||
|
}
|
||||||
|
// metrics.Read indexes into the slice, so an empty buffer has to be skipped.
|
||||||
|
if len(memoryBuffer.samples) > 0 {
|
||||||
|
metrics.Read(memoryBuffer.samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ms runtime.MemStats
|
||||||
|
memStatsFromRM(&ms, memoryBuffer.value)
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
memoryMu sync.Mutex
|
||||||
|
memoryBuffer *memorySamples
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMemorySamples builds the sample buffer from the names this runtime supports.
|
||||||
|
// A name the runtime does not know is left out and reads as zero, which is what
|
||||||
|
// client_golang does as well.
|
||||||
|
func newMemorySamples() *memorySamples {
|
||||||
|
supported := make(map[string]struct{}, len(memoryNames))
|
||||||
|
for _, d := range metrics.All() {
|
||||||
|
supported[d.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
s := &memorySamples{index: make(map[string]int, len(memoryNames))}
|
||||||
|
for _, name := range memoryNames {
|
||||||
|
if _, ok := supported[name]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.index[name] = len(s.samples)
|
||||||
|
s.samples = append(s.samples, metrics.Sample{Name: name})
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// value returns the sampled value of name, or zero when the runtime does not
|
||||||
|
// expose it.
|
||||||
|
func (s *memorySamples) value(name string) uint64 {
|
||||||
|
i, ok := s.index[name]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.samples[i].Value.Uint64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// memStatsFromRM maps runtime/metrics samples onto a runtime.MemStats, using the
|
||||||
|
// same field combinations as client_golang v1.19.1, so the go_memstats_* values
|
||||||
|
// match the ones the official collector reports for the same process. GCCPUFraction
|
||||||
|
// is left at zero, as client_golang omits it too.
|
||||||
|
func memStatsFromRM(ms *runtime.MemStats, lookup func(string) uint64) {
|
||||||
|
// MemStats counts tiny allocations in both Mallocs and Frees. client_golang
|
||||||
|
// keeps that behavior because Mallocs-Frees is then a live object count.
|
||||||
|
tinyAllocs := lookup(rmGCHeapTinyAllocsObjects)
|
||||||
|
ms.Mallocs = lookup(rmGCHeapAllocsObjects) + tinyAllocs
|
||||||
|
ms.Frees = lookup(rmGCHeapFreesObjects) + tinyAllocs
|
||||||
|
|
||||||
|
ms.TotalAlloc = lookup(rmGCHeapAllocsBytes)
|
||||||
|
ms.Sys = lookup(rmMemoryClassesTotalBytes)
|
||||||
|
ms.Lookups = 0 // Already always zero.
|
||||||
|
ms.HeapAlloc = lookup(rmMemoryClassesHeapObjectsBytes)
|
||||||
|
ms.Alloc = ms.HeapAlloc
|
||||||
|
ms.HeapInuse = ms.HeapAlloc + lookup(rmMemoryClassesHeapUnusedBytes)
|
||||||
|
ms.HeapReleased = lookup(rmMemoryClassesHeapReleasedBytes)
|
||||||
|
ms.HeapIdle = ms.HeapReleased + lookup(rmMemoryClassesHeapFreeBytes)
|
||||||
|
ms.HeapSys = ms.HeapInuse + ms.HeapIdle
|
||||||
|
ms.HeapObjects = lookup(rmGCHeapObjects)
|
||||||
|
ms.StackInuse = lookup(rmMemoryClassesHeapStacksBytes)
|
||||||
|
ms.StackSys = ms.StackInuse + lookup(rmMemoryClassesOSStacksBytes)
|
||||||
|
ms.MSpanInuse = lookup(rmMemoryClassesMetadataMSpanInuseBytes)
|
||||||
|
ms.MSpanSys = ms.MSpanInuse + lookup(rmMemoryClassesMetadataMSpanFreeBytes)
|
||||||
|
ms.MCacheInuse = lookup(rmMemoryClassesMetadataMCacheInuseBytes)
|
||||||
|
ms.MCacheSys = ms.MCacheInuse + lookup(rmMemoryClassesMetadataMCacheFreeBytes)
|
||||||
|
ms.BuckHashSys = lookup(rmMemoryClassesProfilingBucketsBytes)
|
||||||
|
ms.GCSys = lookup(rmMemoryClassesMetadataOtherBytes)
|
||||||
|
ms.OtherSys = lookup(rmMemoryClassesOtherBytes)
|
||||||
|
ms.NextGC = lookup(rmGCHeapGoalBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcQuantileLabels matches the quantiles client_golang reports in the
|
||||||
|
// go_gc_duration_seconds summary.
|
||||||
|
var gcQuantileLabels = []string{"0", "0.25", "0.5", "0.75", "1"}
|
||||||
|
|
||||||
|
// runtimeFamilies reads the runtime once per scrape rather than once per metric.
|
||||||
|
func runtimeFamilies() []family {
|
||||||
|
ms := memStatsSnapshot()
|
||||||
|
|
||||||
|
out := make([]family, 0, len(memstatsFamilies)+5)
|
||||||
|
for _, m := range memstatsFamilies {
|
||||||
|
out = append(out, family{
|
||||||
|
name: m.name,
|
||||||
|
help: m.help,
|
||||||
|
typ: m.typ,
|
||||||
|
samples: []sample{{value: m.get(&ms)}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// debug.ReadGCStats only takes the mheap lock; it does not stop the world.
|
||||||
|
var gc debug.GCStats
|
||||||
|
gc.PauseQuantiles = make([]time.Duration, len(gcQuantileLabels))
|
||||||
|
debug.ReadGCStats(&gc)
|
||||||
|
|
||||||
|
gcSamples := make([]sample, 0, len(gcQuantileLabels)+2)
|
||||||
|
for i, q := range gcQuantileLabels {
|
||||||
|
var pause time.Duration
|
||||||
|
if i < len(gc.PauseQuantiles) {
|
||||||
|
pause = gc.PauseQuantiles[i]
|
||||||
|
}
|
||||||
|
gcSamples = append(gcSamples, sample{
|
||||||
|
labels: []label{{name: "quantile", value: q}},
|
||||||
|
value: pause.Seconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
gcSamples = append(gcSamples,
|
||||||
|
sample{name: "go_gc_duration_seconds_sum", value: gc.PauseTotal.Seconds()},
|
||||||
|
sample{name: "go_gc_duration_seconds_count", value: float64(gc.NumGC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return append(out,
|
||||||
|
family{
|
||||||
|
name: "go_gc_duration_seconds",
|
||||||
|
help: "A summary of the pause duration of garbage collection cycles.",
|
||||||
|
typ: typeSummary,
|
||||||
|
samples: gcSamples,
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_goroutines",
|
||||||
|
help: "Number of goroutines that currently exist.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{value: float64(runtime.NumGoroutine())}},
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_info",
|
||||||
|
help: "Information about the Go environment.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{labels: []label{{name: "version", value: runtime.Version()}}, value: 1}},
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_memstats_last_gc_time_seconds",
|
||||||
|
help: "Number of seconds since 1970 of last garbage collection.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{
|
||||||
|
value: float64(gc.LastGC.UnixNano()) / float64(time.Second),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_threads",
|
||||||
|
help: "Number of OS threads created.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{value: float64(threadCount())}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadCount() int {
|
||||||
|
n, _ := runtime.ThreadCreateProfile(nil)
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// 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 text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"runtime"
|
||||||
|
"runtime/metrics"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMemStatsMapping pins the runtime/metrics to MemStats mapping field by
|
||||||
|
// field. The sample values are made up so that every combination shows up: tiny
|
||||||
|
// allocations are added to both Mallocs and Frees, and the combined fields
|
||||||
|
// (HeapInuse, HeapIdle, HeapSys, StackSys, MSpanSys, MCacheSys) add their parts.
|
||||||
|
func TestMemStatsMapping(t *testing.T) {
|
||||||
|
samples := map[string]uint64{
|
||||||
|
rmGCHeapTinyAllocsObjects: 3,
|
||||||
|
rmGCHeapAllocsObjects: 100,
|
||||||
|
rmGCHeapFreesObjects: 40,
|
||||||
|
rmGCHeapAllocsBytes: 1 << 20,
|
||||||
|
rmGCHeapObjects: 900,
|
||||||
|
rmGCHeapGoalBytes: 4 << 20,
|
||||||
|
rmMemoryClassesTotalBytes: 50 << 20,
|
||||||
|
rmMemoryClassesHeapObjectsBytes: 2 << 20,
|
||||||
|
rmMemoryClassesHeapUnusedBytes: 3 << 20,
|
||||||
|
rmMemoryClassesHeapReleasedBytes: 5 << 20,
|
||||||
|
rmMemoryClassesHeapFreeBytes: 7 << 20,
|
||||||
|
rmMemoryClassesHeapStacksBytes: 1 << 20,
|
||||||
|
rmMemoryClassesOSStacksBytes: 2 << 20,
|
||||||
|
rmMemoryClassesMetadataMSpanInuseBytes: 1 << 19,
|
||||||
|
rmMemoryClassesMetadataMSpanFreeBytes: 1 << 18,
|
||||||
|
rmMemoryClassesMetadataMCacheInuseBytes: 1 << 17,
|
||||||
|
rmMemoryClassesMetadataMCacheFreeBytes: 1 << 16,
|
||||||
|
rmMemoryClassesProfilingBucketsBytes: 1 << 15,
|
||||||
|
rmMemoryClassesMetadataOtherBytes: 1 << 14,
|
||||||
|
rmMemoryClassesOtherBytes: 1 << 13,
|
||||||
|
}
|
||||||
|
lookup := func(name string) uint64 { return samples[name] }
|
||||||
|
|
||||||
|
var ms runtime.MemStats
|
||||||
|
memStatsFromRM(&ms, lookup)
|
||||||
|
|
||||||
|
// Expectations are keyed by family name, so this also checks that each family
|
||||||
|
// reads the field the mapping fills.
|
||||||
|
want := map[string]float64{
|
||||||
|
"go_memstats_alloc_bytes": 2 << 20,
|
||||||
|
"go_memstats_alloc_bytes_total": 1 << 20,
|
||||||
|
"go_memstats_buck_hash_sys_bytes": 1 << 15,
|
||||||
|
"go_memstats_frees_total": 43, // 40 frees + 3 tiny allocs
|
||||||
|
"go_memstats_gc_sys_bytes": 1 << 14,
|
||||||
|
"go_memstats_heap_alloc_bytes": 2 << 20,
|
||||||
|
"go_memstats_heap_idle_bytes": 12 << 20, // released + free
|
||||||
|
"go_memstats_heap_inuse_bytes": 5 << 20, // objects + unused
|
||||||
|
"go_memstats_heap_objects": 900,
|
||||||
|
"go_memstats_heap_released_bytes": 5 << 20,
|
||||||
|
"go_memstats_heap_sys_bytes": 17 << 20, // inuse + idle
|
||||||
|
"go_memstats_lookups_total": 0,
|
||||||
|
"go_memstats_mallocs_total": 103, // 100 mallocs + 3 tiny allocs
|
||||||
|
"go_memstats_mcache_inuse_bytes": 1 << 17,
|
||||||
|
"go_memstats_mcache_sys_bytes": 3 << 16, // inuse + free
|
||||||
|
"go_memstats_mspan_inuse_bytes": 1 << 19,
|
||||||
|
"go_memstats_mspan_sys_bytes": 3 << 18, // inuse + free
|
||||||
|
"go_memstats_next_gc_bytes": 4 << 20,
|
||||||
|
"go_memstats_other_sys_bytes": 1 << 13,
|
||||||
|
"go_memstats_stack_inuse_bytes": 1 << 20,
|
||||||
|
"go_memstats_stack_sys_bytes": 3 << 20, // inuse + os-stacks
|
||||||
|
"go_memstats_sys_bytes": 50 << 20,
|
||||||
|
}
|
||||||
|
require.Len(t, want, len(memstatsFamilies), "every family needs an expectation")
|
||||||
|
for _, m := range memstatsFamilies {
|
||||||
|
expected, ok := want[m.name]
|
||||||
|
require.True(t, ok, "%s has no expectation", m.name)
|
||||||
|
require.Equal(t, expected, m.get(&ms), "%s", m.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemorySamplesMissingName checks that a runtime which does not expose a
|
||||||
|
// name reads it as zero instead of failing, the way client_golang behaves.
|
||||||
|
func TestMemorySamplesMissingName(t *testing.T) {
|
||||||
|
s := &memorySamples{
|
||||||
|
samples: []metrics.Sample{{Name: rmGCHeapObjects}},
|
||||||
|
index: map[string]int{rmGCHeapObjects: 0},
|
||||||
|
}
|
||||||
|
require.Equal(t, uint64(0), s.value(rmMemoryClassesTotalBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemStatsSameOrderAsClientGolang compares what frpc reports with what
|
||||||
|
// client_golang reads from the same process at about the same time. The check is
|
||||||
|
// deliberately loose: the two collectors sample the runtime one after the other,
|
||||||
|
// so counters move by whatever the test itself allocates in between, and an exact
|
||||||
|
// comparison would flake.
|
||||||
|
func TestMemStatsSameOrderAsClientGolang(t *testing.T) {
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
reg.MustRegister(collectors.NewGoCollector())
|
||||||
|
mfs, err := reg.Gather()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ours := parseFamilies(t, gather(t))
|
||||||
|
checked := 0
|
||||||
|
for _, mf := range mfs {
|
||||||
|
name := mf.GetName()
|
||||||
|
if !strings.HasPrefix(name, "go_memstats_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name == "go_memstats_last_gc_time_seconds" {
|
||||||
|
// A collection that starts between the two reads jumps this value from
|
||||||
|
// zero to the current time; that is not a difference in behavior.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want, ok := ours[name]
|
||||||
|
require.True(t, ok, "%s is missing", name)
|
||||||
|
reference := metricValue(t, mf.GetMetric()[0])
|
||||||
|
got := metricValue(t, want.GetMetric()[0])
|
||||||
|
require.True(t, sameOrder(reference, got),
|
||||||
|
"%s differs too much: ours %v, client_golang %v", name, got, reference)
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
require.Greater(t, checked, 15, "comparison covered too few families")
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricValue(t *testing.T, m *dto.Metric) float64 {
|
||||||
|
t.Helper()
|
||||||
|
if g := m.GetGauge(); g != nil {
|
||||||
|
return g.GetValue()
|
||||||
|
}
|
||||||
|
if c := m.GetCounter(); c != nil {
|
||||||
|
return c.GetValue()
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected metric type for %s", m.String())
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameOrder reports whether two readings of the same quantity are close enough
|
||||||
|
// that they cannot be different quantities: equal, within 64 KiB (drift while
|
||||||
|
// the test allocates), or within a factor of ten.
|
||||||
|
func sameOrder(a, b float64) bool {
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if math.Abs(a-b) <= 1<<16 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a <= 0 || b <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ratio := a / b
|
||||||
|
return ratio > 0.1 && ratio < 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentScrapes covers the shared runtime/metrics sample buffer: scrapes
|
||||||
|
// run on the listener's goroutines and must not race. Run with -race to check it.
|
||||||
|
func TestConcurrentScrapes(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
reg.RegisterRuntime()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 8 {
|
||||||
|
wg.Go(func() {
|
||||||
|
for range 20 {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := reg.WritePrometheus(&b); err != nil {
|
||||||
|
t.Errorf("scrape failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), "go_memstats_heap_objects") {
|
||||||
|
t.Error("scrape is missing go_memstats_heap_objects")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// 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 text writes Prometheus metrics in the text exposition format.
|
||||||
|
//
|
||||||
|
// frp uses it for frpc, which only exposes Go runtime metrics. Implementing
|
||||||
|
// that subset here keeps client_golang and its protobuf dependency out of the
|
||||||
|
// frpc binary; frps keeps using client_golang, which also gives it the
|
||||||
|
// business metrics and the HTTP handler instrumentation.
|
||||||
|
package text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentType is the content type of the text exposition format.
|
||||||
|
const ContentType = "text/plain; version=0.0.4; charset=utf-8"
|
||||||
|
|
||||||
|
// metricType is the Prometheus metric type.
|
||||||
|
type metricType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
typeCounter metricType = "counter"
|
||||||
|
typeGauge metricType = "gauge"
|
||||||
|
typeSummary metricType = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
type label struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample is one time series. name is empty for plain families, where the
|
||||||
|
// family name is used; summaries set it to carry the _sum and _count suffixes.
|
||||||
|
type sample struct {
|
||||||
|
name string
|
||||||
|
labels []label
|
||||||
|
value float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// family is one metric family ready to be encoded.
|
||||||
|
type family struct {
|
||||||
|
name string
|
||||||
|
help string
|
||||||
|
typ metricType
|
||||||
|
samples []sample
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode writes families in the order they are given.
|
||||||
|
func encode(w io.Writer, families []family) error {
|
||||||
|
for _, f := range families {
|
||||||
|
if _, err := fmt.Fprintf(w, "# HELP %s %s\n", f.name, escapeHelp(f.help)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "# TYPE %s %s\n", f.name, f.typ); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, s := range f.samples {
|
||||||
|
if err := encodeSample(w, f.name, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeSample(w io.Writer, familyName string, s sample) error {
|
||||||
|
name := s.name
|
||||||
|
if name == "" {
|
||||||
|
name = familyName
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(w, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(s.labels) > 0 {
|
||||||
|
if err := encodeLabels(w, s.labels); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(w, " %s\n", formatValue(s.value))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeLabels(w io.Writer, labels []label) error {
|
||||||
|
if _, err := io.WriteString(w, "{"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, l := range labels {
|
||||||
|
if i > 0 {
|
||||||
|
if _, err := io.WriteString(w, ","); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, `%s="%s"`, l.name, escapeLabelValue(l.value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := io.WriteString(w, "}")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLabelValue applies the three escapes the text format defines for
|
||||||
|
// label values.
|
||||||
|
func escapeLabelValue(v string) string {
|
||||||
|
if !strings.ContainsAny(v, "\\\"\n") {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(v) + 8)
|
||||||
|
for _, r := range v {
|
||||||
|
switch r {
|
||||||
|
case '\\':
|
||||||
|
b.WriteString(`\\`)
|
||||||
|
case '"':
|
||||||
|
b.WriteString(`\"`)
|
||||||
|
case '\n':
|
||||||
|
b.WriteString(`\n`)
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeHelp applies the two escapes the text format defines for help text.
|
||||||
|
func escapeHelp(v string) string {
|
||||||
|
if !strings.ContainsAny(v, "\\\n") {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(v) + 8)
|
||||||
|
for _, r := range v {
|
||||||
|
switch r {
|
||||||
|
case '\\':
|
||||||
|
b.WriteString(`\\`)
|
||||||
|
case '\n':
|
||||||
|
b.WriteString(`\n`)
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatValue renders a float the way the Prometheus text format does, so a
|
||||||
|
// sample reads the same whichever implementation produced it.
|
||||||
|
func formatValue(v float64) string {
|
||||||
|
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 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 text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
"github.com/prometheus/common/expfmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gather returns what frpc would serve.
|
||||||
|
func gather(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
reg := NewRegistry()
|
||||||
|
reg.RegisterRuntime()
|
||||||
|
var b strings.Builder
|
||||||
|
require.NoError(t, reg.WritePrometheus(&b))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFamilies decodes the text exposition format with the Prometheus parser,
|
||||||
|
// so anything a scraper would reject fails the test.
|
||||||
|
func parseFamilies(t *testing.T, body string) map[string]*dto.MetricFamily {
|
||||||
|
t.Helper()
|
||||||
|
parser := expfmt.TextParser{}
|
||||||
|
families, err := parser.TextToMetricFamilies(strings.NewReader(body))
|
||||||
|
require.NoError(t, err, "output must be valid text exposition format")
|
||||||
|
return families
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchesClientGolang checks that every go_* family this package emits has
|
||||||
|
// the same name, type and help text as client_golang's default Go collector. The
|
||||||
|
// official collector is used as the oracle, so a difference shows up here rather
|
||||||
|
// than in someone's dashboard.
|
||||||
|
func TestMatchesClientGolang(t *testing.T) {
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
reg.MustRegister(collectors.NewGoCollector())
|
||||||
|
mfs, err := reg.Gather()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reference := make(map[string]*dto.MetricFamily, len(mfs))
|
||||||
|
for _, mf := range mfs {
|
||||||
|
reference[mf.GetName()] = mf
|
||||||
|
}
|
||||||
|
ours := parseFamilies(t, gather(t))
|
||||||
|
|
||||||
|
checked := 0
|
||||||
|
for name, want := range reference {
|
||||||
|
if !strings.HasPrefix(name, "go_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, ok := ours[name]
|
||||||
|
require.True(t, ok, "%s is missing", name)
|
||||||
|
require.Equal(t, want.GetType(), got.GetType(), "%s type", name)
|
||||||
|
require.Equal(t, want.GetHelp(), got.GetHelp(), "%s help", name)
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
require.Greater(t, checked, 20, "comparison covered too few families")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoBusinessMetrics pins what frpc must not expose: no frp_ business metrics,
|
||||||
|
// none of the handler instrumentation client_golang adds, and no process_
|
||||||
|
// families. frpc reports Go runtime metrics only, on every platform.
|
||||||
|
func TestNoBusinessMetrics(t *testing.T) {
|
||||||
|
body := gather(t)
|
||||||
|
require.NotContains(t, body, "frp_")
|
||||||
|
require.NotContains(t, body, "promhttp_")
|
||||||
|
require.NotContains(t, body, "process_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
reg.RegisterRuntime()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Equal(t, ContentType, rec.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
families := parseFamilies(t, rec.Body.String())
|
||||||
|
require.Contains(t, families, "go_goroutines")
|
||||||
|
gc, ok := families["go_gc_duration_seconds"]
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, dto.MetricType_SUMMARY, gc.GetType())
|
||||||
|
require.Len(t, gc.GetMetric()[0].GetSummary().GetQuantile(), 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyRegistry(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
require.NoError(t, NewRegistry().WritePrometheus(&b))
|
||||||
|
require.Empty(t, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscaping(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
require.NoError(t, encode(&b, []family{{
|
||||||
|
name: "test_labels",
|
||||||
|
help: "help with \\ and\nnewline",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{labels: []label{{name: "v", value: "a\"b\\c\nd"}}, value: 1}},
|
||||||
|
}}))
|
||||||
|
require.Equal(t, "# HELP test_labels help with \\\\ and\\nnewline\n"+
|
||||||
|
"# TYPE test_labels gauge\n"+
|
||||||
|
"test_labels{v=\"a\\\"b\\\\c\\nd\"} 1\n", b.String())
|
||||||
|
}
|
||||||
+55
-4
@@ -19,6 +19,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/proto/wire"
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
)
|
)
|
||||||
@@ -111,14 +113,22 @@ type Dispatcher struct {
|
|||||||
|
|
||||||
sendCh chan Message
|
sendCh chan Message
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
msgHandlers map[reflect.Type]func(Message)
|
msgHandlers map[reflect.Type]func(Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDispatcher uses SetReadDeadline, when supported, and Close, when rw
|
||||||
|
// implements io.Closer, to interrupt ReadMsg after a write failure. Without a
|
||||||
|
// working read deadline, Close or the caller must arrange for ReadMsg to return.
|
||||||
|
// These methods must return in bounded time and must not wait for Done: Done
|
||||||
|
// closes only after ReadMsg, any synchronous handler, and stop have returned.
|
||||||
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
||||||
return &Dispatcher{
|
return &Dispatcher{
|
||||||
rw: rw,
|
rw: rw,
|
||||||
sendCh: make(chan Message, 100),
|
sendCh: make(chan Message, 100),
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
msgHandlers: make(map[reflect.Type]func(Message)),
|
msgHandlers: make(map[reflect.Type]func(Message)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,39 +139,80 @@ func (d *Dispatcher) Run() {
|
|||||||
go d.readLoop()
|
go d.readLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stop rejects new sends and interrupts ReadMsg when supported by rw. Done remains
|
||||||
|
// owned by readLoop so the control owner cannot clean up while a handler is running.
|
||||||
|
func (d *Dispatcher) stop() {
|
||||||
|
d.stopOnce.Do(func() {
|
||||||
|
close(d.stopCh)
|
||||||
|
if conn, ok := d.rw.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||||
|
_ = conn.SetReadDeadline(time.Now())
|
||||||
|
}
|
||||||
|
if closer, ok := d.rw.(io.Closer); ok {
|
||||||
|
_ = closer.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) sendLoop() {
|
func (d *Dispatcher) sendLoop() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-d.doneCh:
|
case <-d.stopCh:
|
||||||
return
|
return
|
||||||
case m := <-d.sendCh:
|
case m := <-d.sendCh:
|
||||||
_ = d.rw.WriteMsg(m)
|
if err := d.rw.WriteMsg(m); err != nil {
|
||||||
|
d.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) readLoop() {
|
func (d *Dispatcher) readLoop() {
|
||||||
|
defer close(d.doneCh)
|
||||||
|
defer d.stop()
|
||||||
for {
|
for {
|
||||||
m, err := d.rw.ReadMsg()
|
m, err := d.rw.ReadMsg()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
close(d.doneCh)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-d.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
||||||
handler(m)
|
handler(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send queues m for writing. A nil error does not guarantee delivery.
|
||||||
func (d *Dispatcher) Send(m Message) error {
|
func (d *Dispatcher) Send(m Message) error {
|
||||||
select {
|
select {
|
||||||
case <-d.doneCh:
|
case <-d.stopCh:
|
||||||
|
return io.EOF
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return d.enqueue(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) enqueue(m Message) error {
|
||||||
|
select {
|
||||||
|
case <-d.stopCh:
|
||||||
return io.EOF
|
return io.EOF
|
||||||
case d.sendCh <- m:
|
case d.sendCh <- m:
|
||||||
|
// Both cases can be ready if shutdown races with enqueueing. Do not
|
||||||
|
// report success when shutdown is already observable after the send.
|
||||||
|
select {
|
||||||
|
case <-d.stopCh:
|
||||||
|
return io.EOF
|
||||||
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
|
func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
|
||||||
d.msgHandlers[reflect.TypeOf(msg)] = handler
|
d.msgHandlers[reflect.TypeOf(msg)] = handler
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preserve the original exported constructor function type.
|
||||||
|
var _ func(ReadWriter) *Dispatcher = NewDispatcher
|
||||||
|
|
||||||
|
func TestDispatcherEnqueueRejectsStoppedDispatcher(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
d := NewDispatcher(c)
|
||||||
|
d.stop()
|
||||||
|
|
||||||
|
// Model shutdown after Send's initial check: the stop and buffered send
|
||||||
|
// cases are both ready. A send selected here must still return EOF.
|
||||||
|
if err := d.enqueue(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("enqueue racing with shutdown: %v, want EOF", err)
|
||||||
|
}
|
||||||
|
// This single call checks that a stopped dispatcher's enqueue returns EOF; it
|
||||||
|
// does not guarantee exercising the send case and post-enqueue check on every run.
|
||||||
|
if len(d.sendCh) > 0 {
|
||||||
|
t.Log("selected the send case; post-enqueue check returned EOF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherAcceptsReadWriterWithoutClose(t *testing.T) {
|
||||||
|
t.Run("codec factory", func(t *testing.T) {
|
||||||
|
d := NewDispatcher(NewV1ReadWriter(&bytes.Buffer{}))
|
||||||
|
d.Run()
|
||||||
|
regressionAwait(t, d.Done(), "non-closable codec EOF")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("write failure", func(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
regressionWarm(t, c)
|
||||||
|
// Hide Close to model an existing caller implementing only ReadWriter.
|
||||||
|
d := NewDispatcher(struct{ ReadWriter }{c})
|
||||||
|
d.Run()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
c.transport.close()
|
||||||
|
regressionAwait(t, d.Done(), "externally interrupted reader")
|
||||||
|
})
|
||||||
|
|
||||||
|
c.transport.arm()
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||||
|
t.Fatalf("injected write failure: %v", err)
|
||||||
|
}
|
||||||
|
regressionAwait(t, d.stopCh, "write failure stops sends")
|
||||||
|
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("Send after write failure: %v, want EOF", err)
|
||||||
|
}
|
||||||
|
if c.closeCalls.Load() != 0 {
|
||||||
|
t.Fatal("dispatcher closed a connection hidden by ReadWriter")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-d.Done():
|
||||||
|
t.Fatal("Done closed before the non-closable reader returned")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libcrypto "github.com/fatedier/golib/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errRegressionWriteTimeout = errors.New("injected recoverable connection write timeout")
|
||||||
|
regressionKey = []byte("isolated-public-test-key")
|
||||||
|
)
|
||||||
|
|
||||||
|
// regressionTransport fails exactly one Write, then accepts later writes. This
|
||||||
|
// models an individual mux stream write timing out while its session survives.
|
||||||
|
type regressionTransport struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buffer bytes.Buffer
|
||||||
|
calls int
|
||||||
|
failures int
|
||||||
|
failNext bool
|
||||||
|
closed chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *regressionTransport) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
w.calls++
|
||||||
|
if w.failNext {
|
||||||
|
w.failNext = false
|
||||||
|
w.failures++
|
||||||
|
return 0, errRegressionWriteTimeout
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-w.closed:
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return w.buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *regressionTransport) arm() {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.failNext = true
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *regressionTransport) snapshot() (calls, failures, size int) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.calls, w.failures, w.buffer.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *regressionTransport) close() {
|
||||||
|
w.once.Do(func() { close(w.closed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// regressionControl uses the production FRP message encoder and production
|
||||||
|
// golib crypto.Writer. Only the final transport and incoming messages are fake.
|
||||||
|
type regressionControl struct {
|
||||||
|
w *libcrypto.Writer
|
||||||
|
transport *regressionTransport
|
||||||
|
writes chan error
|
||||||
|
incoming chan Message
|
||||||
|
beforeRead func() error
|
||||||
|
beforeWrite func()
|
||||||
|
closeCalls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegressionControl(t *testing.T) *regressionControl {
|
||||||
|
t.Helper()
|
||||||
|
transport := ®ressionTransport{closed: make(chan struct{})}
|
||||||
|
w, err := libcrypto.NewWriter(transport, regressionKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ®ressionControl{
|
||||||
|
w: w, transport: transport, writes: make(chan error, 100), incoming: make(chan Message, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *regressionControl) ReadMsg() (Message, error) {
|
||||||
|
if c.beforeRead != nil {
|
||||||
|
if err := c.beforeRead(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m := <-c.incoming:
|
||||||
|
return m, nil
|
||||||
|
case <-c.transport.closed:
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *regressionControl) ReadMsgInto(Message) error {
|
||||||
|
_, err := c.ReadMsg()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *regressionControl) WriteMsg(m Message) error {
|
||||||
|
if c.beforeWrite != nil {
|
||||||
|
c.beforeWrite()
|
||||||
|
}
|
||||||
|
err := WriteMsg(c.w, m)
|
||||||
|
c.writes <- err
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *regressionControl) Close() error {
|
||||||
|
c.closeCalls.Add(1)
|
||||||
|
c.transport.close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func regressionReceiveWrite(t *testing.T, c *regressionControl) error {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case err := <-c.writes:
|
||||||
|
return err
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("dispatcher did not attempt WriteMsg")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func regressionAwait(t *testing.T, ch <-chan struct{}, what string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("timed out waiting for %s", what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func regressionWarm(t *testing.T, c *regressionControl) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.WriteMsg(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-c.writes
|
||||||
|
c.transport.mu.Lock()
|
||||||
|
raw := append([]byte(nil), c.transport.buffer.Bytes()...)
|
||||||
|
c.transport.mu.Unlock()
|
||||||
|
m, err := ReadMsg(libcrypto.NewReader(bytes.NewReader(raw), regressionKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypted control roundtrip: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := m.(*ReqWorkConn); !ok {
|
||||||
|
t.Fatalf("decoded %T", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlCryptoWriterKeepsErrorAfterTransportRecovery(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
regressionWarm(t, c)
|
||||||
|
defer c.transport.close()
|
||||||
|
c.transport.arm()
|
||||||
|
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
|
||||||
|
t.Fatalf("want timeout, got %v", err)
|
||||||
|
}
|
||||||
|
<-c.writes
|
||||||
|
beforeCalls, failures, beforeBytes := c.transport.snapshot()
|
||||||
|
for range 5 {
|
||||||
|
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
|
||||||
|
t.Fatalf("sticky error missing: %v", err)
|
||||||
|
}
|
||||||
|
<-c.writes
|
||||||
|
}
|
||||||
|
afterCalls, _, afterBytes := c.transport.snapshot()
|
||||||
|
if beforeCalls != afterCalls || beforeBytes != afterBytes || failures != 1 {
|
||||||
|
t.Fatalf("unexpected retry: calls %d -> %d, failures %d, bytes %d -> %d",
|
||||||
|
beforeCalls, afterCalls, failures, beforeBytes, afterBytes)
|
||||||
|
}
|
||||||
|
if n, err := c.transport.Write([]byte("healthy underlying transport")); err != nil || n == 0 {
|
||||||
|
t.Fatalf("underlying transport did not recover: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("one recoverable write error permanently invalidates this crypto writer; recovered underlying transport is never retried")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherWriteErrorClosesControlAndFreshSessionWorks(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
regressionWarm(t, c)
|
||||||
|
defer c.transport.close()
|
||||||
|
d := NewDispatcher(c)
|
||||||
|
d.Run()
|
||||||
|
c.transport.arm()
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
regressionAwait(t, c.transport.closed, "failed control connection close")
|
||||||
|
regressionAwait(t, d.Done(), "failed dispatcher completion")
|
||||||
|
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("Send after completed shutdown: %v", err)
|
||||||
|
}
|
||||||
|
if c.closeCalls.Load() != 1 {
|
||||||
|
t.Fatalf("connection closed %d times", c.closeCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh := newRegressionControl(t)
|
||||||
|
defer fresh.transport.close()
|
||||||
|
d2 := NewDispatcher(fresh)
|
||||||
|
d2.Run()
|
||||||
|
for range 5 {
|
||||||
|
if err := d2.Send(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := regressionReceiveWrite(t, fresh); err != nil {
|
||||||
|
t.Fatalf("fresh session write failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fresh.transport.mu.Lock()
|
||||||
|
raw := append([]byte(nil), fresh.transport.buffer.Bytes()...)
|
||||||
|
fresh.transport.mu.Unlock()
|
||||||
|
r := libcrypto.NewReader(bytes.NewReader(raw), regressionKey)
|
||||||
|
for i := range 5 {
|
||||||
|
m, err := ReadMsg(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new stream decode %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if _, ok := m.(*ReqWorkConn); !ok {
|
||||||
|
t.Fatalf("decoded %T", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fresh.transport.close()
|
||||||
|
regressionAwait(t, d2.Done(), "new-session cleanup")
|
||||||
|
t.Log("write error closes failed control; new session sends and decrypts five valid ReqWorkConn messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherWriteErrorWaitsForSynchronousHandler(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
regressionWarm(t, c)
|
||||||
|
defer c.transport.close()
|
||||||
|
d := NewDispatcher(c)
|
||||||
|
entered, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
defer releaseOnce.Do(func() { close(release) })
|
||||||
|
d.RegisterHandler(&ReqWorkConn{}, func(Message) {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
})
|
||||||
|
d.Run()
|
||||||
|
c.incoming <- &ReqWorkConn{}
|
||||||
|
regressionAwait(t, entered, "synchronous handler entry")
|
||||||
|
c.transport.arm()
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
regressionAwait(t, c.transport.closed, "write failure closes control during handler")
|
||||||
|
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("Send after stop while handler is active: %v", err)
|
||||||
|
}
|
||||||
|
// Let the writer finish stopping while the handler remains blocked.
|
||||||
|
synctest.Wait()
|
||||||
|
select {
|
||||||
|
case <-d.Done():
|
||||||
|
t.Fatal("Done closed before synchronous handler returned; owner cleanup could race proxy registration")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
releaseOnce.Do(func() { close(release) })
|
||||||
|
regressionAwait(t, d.Done(), "completion after handler returned")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherWriteErrorUnblocksSenderWithFullQueue(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
regressionWarm(t, c)
|
||||||
|
defer c.transport.close()
|
||||||
|
entered, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
defer releaseOnce.Do(func() { close(release) })
|
||||||
|
c.beforeWrite = func() { close(entered); <-release }
|
||||||
|
c.transport.arm()
|
||||||
|
d := NewDispatcher(c)
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Run()
|
||||||
|
regressionAwait(t, entered, "blocked transport write")
|
||||||
|
for i := 0; i < cap(d.sendCh); i++ {
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent := make(chan error, 1)
|
||||||
|
go func() { sent <- d.Send(&ReqWorkConn{}) }()
|
||||||
|
// Wait until the sender has reached its full-queue channel wait.
|
||||||
|
synctest.Wait()
|
||||||
|
select {
|
||||||
|
case err := <-sent:
|
||||||
|
t.Fatalf("Send did not block on full queue: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
releaseOnce.Do(func() { close(release) })
|
||||||
|
select {
|
||||||
|
case err := <-sent:
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("blocked Send after write failure: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("write failure did not unblock full-queue sender")
|
||||||
|
}
|
||||||
|
regressionAwait(t, d.Done(), "full-queue dispatcher cleanup")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherConcurrentReadWriteFailureClosesOnce(t *testing.T) {
|
||||||
|
c := newRegressionControl(t)
|
||||||
|
regressionWarm(t, c)
|
||||||
|
defer c.transport.close()
|
||||||
|
readEntered, writeEntered := make(chan struct{}), make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
defer releaseOnce.Do(func() { close(release) })
|
||||||
|
c.beforeRead = func() error {
|
||||||
|
close(readEntered)
|
||||||
|
<-release
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
c.beforeWrite = func() {
|
||||||
|
close(writeEntered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
d := NewDispatcher(c)
|
||||||
|
c.transport.arm()
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
readExited, writeExited := make(chan struct{}), make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(readExited)
|
||||||
|
d.readLoop()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer close(writeExited)
|
||||||
|
d.sendLoop()
|
||||||
|
}()
|
||||||
|
regressionAwait(t, readEntered, "read path before injected failure")
|
||||||
|
regressionAwait(t, writeEntered, "write path before injected failure")
|
||||||
|
releaseOnce.Do(func() { close(release) })
|
||||||
|
regressionAwait(t, readExited, "failed read loop exit")
|
||||||
|
regressionAwait(t, writeExited, "failed write loop exit")
|
||||||
|
regressionAwait(t, d.Done(), "concurrent read/write shutdown")
|
||||||
|
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||||
|
t.Fatalf("concurrent WriteMsg failure: %v", err)
|
||||||
|
}
|
||||||
|
if _, failures, _ := c.transport.snapshot(); failures != 1 {
|
||||||
|
t.Fatalf("injected transport failed %d times", failures)
|
||||||
|
}
|
||||||
|
if c.closeCalls.Load() != 1 {
|
||||||
|
t.Fatalf("connection closed %d times", c.closeCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fmux "github.com/fatedier/yamux"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delay one real TCP write without injecting an error. The stream's write
|
||||||
|
// failure must come from yamux's ConnectionWriteTimeout.
|
||||||
|
type delayedTCPConn struct {
|
||||||
|
net.Conn
|
||||||
|
armed atomic.Bool
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *delayedTCPConn) Write(p []byte) (int, error) {
|
||||||
|
if c.armed.Swap(false) {
|
||||||
|
close(c.entered)
|
||||||
|
<-c.release
|
||||||
|
}
|
||||||
|
return c.Conn.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
type observedYamuxConn struct {
|
||||||
|
net.Conn
|
||||||
|
writes chan error
|
||||||
|
readEntered chan struct{}
|
||||||
|
readOnce sync.Once
|
||||||
|
closed chan struct{}
|
||||||
|
closeCalls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *observedYamuxConn) Read(p []byte) (int, error) {
|
||||||
|
c.readOnce.Do(func() { close(c.readEntered) })
|
||||||
|
return c.Conn.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *observedYamuxConn) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Write(p)
|
||||||
|
c.writes <- err
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *observedYamuxConn) Close() error {
|
||||||
|
c.closeCalls.Add(1)
|
||||||
|
err := c.Conn.Close()
|
||||||
|
close(c.closed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoopbackYamuxPair(t *testing.T) (*fmux.Session, *fmux.Session, *delayedTCPConn) {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
clientTCP, err := net.Dial("tcp", listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { clientTCP.Close() })
|
||||||
|
serverTCP, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { serverTCP.Close() })
|
||||||
|
gate := &delayedTCPConn{Conn: clientTCP, entered: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
config := fmux.DefaultConfig()
|
||||||
|
config.ConnectionWriteTimeout = 80 * time.Millisecond
|
||||||
|
config.LogOutput = io.Discard
|
||||||
|
client, err := fmux.Client(gate, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { client.Close() })
|
||||||
|
server, err := fmux.Server(serverTCP, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { server.Close() })
|
||||||
|
return client, server, gate
|
||||||
|
}
|
||||||
|
|
||||||
|
func loopbackCryptoConn(t *testing.T, conn net.Conn, protocol string, role netpkg.AEADCryptoRole) *Conn {
|
||||||
|
t.Helper()
|
||||||
|
var rw io.ReadWriter
|
||||||
|
var err error
|
||||||
|
if protocol == wire.ProtocolV2 {
|
||||||
|
rw, err = netpkg.NewAEADCryptoReadWriter(
|
||||||
|
conn, regressionKey, role, wire.AEADAlgorithmAES256GCM, []byte("isolated-test-transcript"))
|
||||||
|
} else {
|
||||||
|
rw, err = netpkg.NewCryptoReadWriter(conn, regressionKey)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return NewConn(conn, NewReadWriter(rw, protocol))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loopbackControlPair(t *testing.T, client, server *fmux.Session, protocol string) (*Conn, *Conn, *observedYamuxConn) {
|
||||||
|
t.Helper()
|
||||||
|
local, err := client.OpenStream()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
remote, err := server.AcceptStream()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
observed := &observedYamuxConn{
|
||||||
|
Conn: local, writes: make(chan error, 16), readEntered: make(chan struct{}), closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
return loopbackCryptoConn(t, observed, protocol, netpkg.AEADCryptoRoleClient),
|
||||||
|
loopbackCryptoConn(t, remote, protocol, netpkg.AEADCryptoRoleServer), observed
|
||||||
|
}
|
||||||
|
|
||||||
|
func loopbackReadReq(t *testing.T, conn *Conn) {
|
||||||
|
t.Helper()
|
||||||
|
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.SetReadDeadline(time.Time{})
|
||||||
|
m, err := conn.ReadMsg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := m.(*ReqWorkConn); !ok {
|
||||||
|
t.Fatalf("decoded %T, want ReqWorkConn", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
|
||||||
|
for _, protocol := range []string{wire.ProtocolV1, wire.ProtocolV2} {
|
||||||
|
t.Run(protocol, func(t *testing.T) {
|
||||||
|
client, server, gate := newLoopbackYamuxPair(t)
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
defer releaseOnce.Do(func() { close(gate.release) })
|
||||||
|
control, peer, observed := loopbackControlPair(t, client, server, protocol)
|
||||||
|
if err := control.WriteMsg(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loopbackReadReq(t, peer)
|
||||||
|
// Warm-up has completed, including all crypto header writes.
|
||||||
|
for len(observed.writes) > 0 {
|
||||||
|
<-observed.writes
|
||||||
|
}
|
||||||
|
d := NewDispatcher(control)
|
||||||
|
readExited := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(readExited)
|
||||||
|
d.readLoop()
|
||||||
|
}()
|
||||||
|
regressionAwait(t, observed.readEntered, "control reader entry")
|
||||||
|
gate.armed.Store(true)
|
||||||
|
// Queue before starting the writer so this Send cannot race its failure.
|
||||||
|
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go d.sendLoop()
|
||||||
|
regressionAwait(t, gate.entered, "TCP write delay barrier")
|
||||||
|
select {
|
||||||
|
case err := <-observed.writes:
|
||||||
|
if !errors.Is(err, fmux.ErrConnectionWriteTimeout) {
|
||||||
|
t.Fatalf("stream write error: %v, want yamux connection write timeout", err)
|
||||||
|
}
|
||||||
|
t.Logf("production yamux write failed: %v", err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("yamux write did not time out")
|
||||||
|
}
|
||||||
|
regressionAwait(t, d.stopCh, "write failure stops sends")
|
||||||
|
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("Send after stop: %v, want EOF", err)
|
||||||
|
}
|
||||||
|
releaseOnce.Do(func() { close(gate.release) })
|
||||||
|
regressionAwait(t, observed.closed, "control Close returns")
|
||||||
|
// peer never closes its write half: local yamux Close alone cannot
|
||||||
|
// interrupt Read. Neither a peer FIN nor session cleanup may help.
|
||||||
|
regressionAwait(t, d.Done(), "Done without peer FIN")
|
||||||
|
regressionAwait(t, readExited, "control readLoop exit without peer FIN")
|
||||||
|
if observed.closeCalls.Load() != 1 {
|
||||||
|
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
|
||||||
|
}
|
||||||
|
if client.IsClosed() || server.IsClosed() {
|
||||||
|
t.Fatal("yamux session unexpectedly closed")
|
||||||
|
}
|
||||||
|
fresh, freshPeer, freshObserved := loopbackControlPair(t, client, server, protocol)
|
||||||
|
for range 5 {
|
||||||
|
if err := fresh.WriteMsg(&ReqWorkConn{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loopbackReadReq(t, freshPeer)
|
||||||
|
for len(freshObserved.writes) > 0 {
|
||||||
|
<-freshObserved.writes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Log("Done/readLoop exited without peer FIN; Close once; same yamux session decrypted five fresh messages")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,6 @@ type NewProxy struct {
|
|||||||
Headers map[string]string `json:"headers,omitempty"`
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
||||||
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
||||||
HTTPRedirect bool `json:"http_redirect,omitempty"`
|
|
||||||
|
|
||||||
// stcp, sudp, xtcp
|
// stcp, sudp, xtcp
|
||||||
Sk string `json:"sk,omitempty"`
|
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
|
V2TypeNatHoleResp uint16 = 16
|
||||||
V2TypeNatHoleSid uint16 = 17
|
V2TypeNatHoleSid uint16 = 17
|
||||||
V2TypeNatHoleReport uint16 = 18
|
V2TypeNatHoleReport uint16 = 18
|
||||||
|
V2TypeUDPPacketBinary uint16 = 19
|
||||||
)
|
)
|
||||||
|
|
||||||
var v2MsgTypeMap = map[uint16]any{
|
var v2MsgTypeMap = map[uint16]any{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user