forked from Mxmilu666/frp
Compare commits
41
Commits
386df7f426
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -7,14 +7,15 @@ jobs:
|
|||||||
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
|
||||||
|
git diff --exit-code
|
||||||
|
make build
|
||||||
- run: make alltest
|
- run: make alltest
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
|
|||||||
@@ -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,192 +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 被覆盖
|
|
||||||
echo "Building frps..."
|
|
||||||
go build -trimpath -ldflags "-s -w" -tags frps -o bin/frps ./cmd/frps
|
|
||||||
|
|
||||||
echo "Building frpc..."
|
|
||||||
go build -trimpath -ldflags "-s -w" -tags frpc -o bin/frpc ./cmd/frpc
|
|
||||||
|
|
||||||
# 验证文件已生成
|
|
||||||
ls -lh ./bin/
|
|
||||||
file ./bin/frpc
|
|
||||||
file ./bin/frps
|
|
||||||
|
|
||||||
out_dir="release/packages/frp_${version}_android_arm"
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
|
|
||||||
mv ./bin/frpc "$out_dir/frpc"
|
|
||||||
mv ./bin/frps "$out_dir/frps"
|
|
||||||
|
|
||||||
cp LICENSE "$out_dir"
|
|
||||||
cp -f conf/frpc.toml "$out_dir"
|
|
||||||
cp -f conf/frps.toml "$out_dir"
|
|
||||||
|
|
||||||
echo "Build completed for android-arm"
|
|
||||||
ls -lh "$out_dir"
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: LoliaFrp_android_arm
|
|
||||||
path: |
|
|
||||||
release/packages/frp_*
|
|
||||||
retention-days: 7
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Build Image and Publish to Dockerhub & GPR
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [ published ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Image tag'
|
||||||
|
required: true
|
||||||
|
default: 'test'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
name: Build Image from Dockerfile and binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# environment
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: '0'
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
# get image tag name
|
||||||
|
- name: Get Image Tag Name
|
||||||
|
run: |
|
||||||
|
if [ x${{ github.event.inputs.tag }} == x"" ]; then
|
||||||
|
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to the GPR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GPR_TOKEN }}
|
||||||
|
|
||||||
|
# prepare image tags
|
||||||
|
- name: Prepare Image Tags
|
||||||
|
run: |
|
||||||
|
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
|
||||||
|
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push frpc
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./dockerfiles/Dockerfile-for-frpc
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.TAG_FRPC }}
|
||||||
|
${{ env.TAG_FRPC_GPR }}
|
||||||
|
|
||||||
|
- name: Build and push frps
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./dockerfiles/Dockerfile-for-frps
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.TAG_FRPS }}
|
||||||
|
${{ env.TAG_FRPS_GPR }}
|
||||||
@@ -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
|
|
||||||
|
|
||||||
- name: Generate image digest
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: |
|
|
||||||
echo "Image pushed to: ${{ matrix.image_name }}"
|
|
||||||
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
|
||||||
@@ -22,12 +22,8 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',n
|
|||||||
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
|
||||||
|
|
||||||
@@ -16,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">
|
||||||
@@ -30,16 +39,6 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</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?
|
||||||
|
|||||||
+10
-10
@@ -15,6 +15,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">
|
||||||
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</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 ?
|
||||||
|
|||||||
+4
-8
@@ -1,9 +1,5 @@
|
|||||||
## Features
|
## Fixes
|
||||||
|
|
||||||
* `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.
|
* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported.
|
||||||
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
* Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection.
|
||||||
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
* Fixed an SSH tunnel gateway panic when handling malformed exec requests.
|
||||||
|
|
||||||
## Compatibility Notes
|
|
||||||
|
|
||||||
* 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.
|
|
||||||
|
|||||||
+2
-39
@@ -16,9 +16,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -162,44 +160,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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -72,7 +70,6 @@ func NewProxy(
|
|||||||
|
|
||||||
baseProxy := BaseProxy{
|
baseProxy := BaseProxy{
|
||||||
baseCfg: pxyConf.GetBaseConfig(),
|
baseCfg: pxyConf.GetBaseConfig(),
|
||||||
configurer: pxyConf,
|
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
@@ -91,7 +88,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
|
||||||
@@ -111,7 +107,6 @@ 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,39 +117,6 @@ func (pxy *BaseProxy) Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *BaseProxy) getPluginHostAllowList() []string {
|
|
||||||
dedupHosts := make([]string, 0)
|
|
||||||
addHost := func(host string) {
|
|
||||||
host = strings.TrimSpace(strings.ToLower(host))
|
|
||||||
if host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// autocert.HostWhitelist only supports exact host names.
|
|
||||||
if strings.Contains(host, "*") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !slices.Contains(dedupHosts, host) {
|
|
||||||
dedupHosts = append(dedupHosts, host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch cfg := pxy.configurer.(type) {
|
|
||||||
case *v1.HTTPProxyConfig:
|
|
||||||
for _, host := range cfg.CustomDomains {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
case *v1.HTTPSProxyConfig:
|
|
||||||
for _, host := range cfg.CustomDomains {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
case *v1.TCPMuxProxyConfig:
|
|
||||||
for _, host := range cfg.CustomDomains {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dedupHosts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pxy *BaseProxy) Close() {
|
func (pxy *BaseProxy) Close() {
|
||||||
if pxy.proxyPlugin != nil {
|
if pxy.proxyPlugin != nil {
|
||||||
pxy.proxyPlugin.Close()
|
pxy.proxyPlugin.Close()
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ 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)
|
||||||
@@ -177,6 +177,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -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"
|
||||||
@@ -109,6 +110,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.
|
||||||
@@ -149,8 +153,7 @@ type Service struct {
|
|||||||
// service context
|
// service context
|
||||||
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
|
|
||||||
|
|
||||||
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
|
||||||
@@ -260,7 +263,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||||
svr.stop()
|
svr.stop()
|
||||||
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
|
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go svr.keepControllerWorking()
|
go svr.keepControllerWorking()
|
||||||
@@ -305,7 +308,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 +319,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})
|
||||||
}
|
}
|
||||||
@@ -412,7 +415,7 @@ 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.cancel(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +432,8 @@ 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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+2
-13
@@ -48,19 +48,8 @@ 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] != "" {
|
if err != nil {
|
||||||
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
|
||||||
if err != nil {
|
|
||||||
cfg = &v1.ClientCommonConfig{}
|
|
||||||
if err := cfg.Complete(); err != nil {
|
|
||||||
fmt.Printf("failed to complete config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cfg = loaded
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cfg = &v1.ClientCommonConfig{}
|
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)
|
||||||
|
|||||||
@@ -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
-252
@@ -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"
|
||||||
@@ -38,28 +35,24 @@ import (
|
|||||||
"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/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 +68,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 +106,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 {
|
||||||
@@ -231,7 +186,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 +194,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 +217,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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ 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)
|
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
|
|||||||
@@ -336,14 +336,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"
|
||||||
|
|
||||||
@@ -356,14 +348,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"
|
||||||
|
|
||||||
@@ -377,15 +361,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"
|
||||||
@@ -405,14 +380,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"
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ go 1.25.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.1
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
|
||||||
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
|
||||||
@@ -15,10 +13,9 @@ require (
|
|||||||
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.7.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/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 +25,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.54.0
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.56.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
|
||||||
@@ -43,16 +40,10 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/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 +52,20 @@ 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/client_model v0.5.0 // indirect
|
||||||
github.com/prometheus/common v0.48.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.40.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/tools v0.47.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
|
||||||
@@ -97,5 +76,3 @@ require (
|
|||||||
|
|
||||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
// 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
|
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
||||||
|
|
||||||
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.7.4
|
|
||||||
|
|||||||
@@ -2,33 +2,17 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
|||||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
github.com/Azure/go-ntlmssp v0.1.0/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/Lolia-FRP/golib v0.7.4 h1:EtUJChdgXVV9iU1xqB7PkD/1/3RgATbj3n86HbrLKMA=
|
|
||||||
github.com/Lolia-FRP/golib v0.7.4/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 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=
|
||||||
@@ -36,12 +20,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.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.8.1 h1:pHcIu0zAcZ6VTkO1dW/meelCGN5nem52DKCBY7cUvyA=
|
||||||
|
github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.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,29 +70,14 @@ 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/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
|
||||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
|
||||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
|
||||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
|
||||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
|
||||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
|
||||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
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=
|
||||||
@@ -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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
golang.org/x/time v0.10.0 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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@@ -25,31 +24,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PluginHTTP2HTTPS = "http2https"
|
PluginHTTP2HTTPS = "http2https"
|
||||||
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
PluginHTTPProxy = "http_proxy"
|
||||||
PluginHTTPProxy = "http_proxy"
|
PluginHTTPS2HTTP = "https2http"
|
||||||
PluginHTTPS2HTTP = "https2http"
|
PluginHTTPS2HTTPS = "https2https"
|
||||||
PluginHTTPS2HTTPS = "https2https"
|
PluginHTTP2HTTP = "http2http"
|
||||||
PluginHTTP2HTTP = "http2http"
|
PluginSocks5 = "socks5"
|
||||||
PluginSocks5 = "socks5"
|
PluginStaticFile = "static_file"
|
||||||
PluginStaticFile = "static_file"
|
PluginUnixDomainSocket = "unix_domain_socket"
|
||||||
PluginUnixDomainSocket = "unix_domain_socket"
|
PluginTLS2Raw = "tls2raw"
|
||||||
PluginTLS2Raw = "tls2raw"
|
PluginVirtualNet = "virtual_net"
|
||||||
PluginVirtualNet = "virtual_net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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](),
|
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientPluginOptions interface {
|
type ClientPluginOptions interface {
|
||||||
@@ -83,29 +80,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 +98,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,7 +122,6 @@ 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() {
|
||||||
@@ -177,7 +135,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,7 +146,6 @@ 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() {
|
||||||
@@ -203,7 +159,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +230,10 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TLS2RawPluginOptions struct {
|
type TLS2RawPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
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 +243,6 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := *o
|
out := *o
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -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,13 +267,8 @@ 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
|
res = toProxyStats(proxyName, proxyStats)
|
||||||
// knowing its type (proxyType == "" or "all").
|
|
||||||
filterAll := proxyType == "" || proxyType == "all"
|
|
||||||
if filterAll || proxyStats.ProxyType == proxyType {
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
|
|||||||
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
||||||
vResp, cResp, err := c.analysis(session)
|
vResp, cResp, err := c.analysis(session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("sid [%s] analysis error: %v", sid, err)
|
log.Debugf("sid [%s] analysis error: %v", err)
|
||||||
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
||||||
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
||||||
}
|
}
|
||||||
@@ -385,6 +385,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
|||||||
if !isLast {
|
if !isLast {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ports := make([]msg.PortsRange, 0, 1)
|
||||||
_, portStr, err := net.SplitHostPort(addr)
|
_, portStr, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -393,8 +394,9 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []msg.PortsRange{{
|
ports = append(ports, msg.PortsRange{
|
||||||
From: max(port-difference-5, port-maxNumber, 1),
|
From: max(port-difference-5, port-maxNumber, 1),
|
||||||
To: min(port+difference+5, port+maxNumber, 65535),
|
To: min(port+difference+5, port+maxNumber, 65535),
|
||||||
}}
|
})
|
||||||
|
return ports
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-65
@@ -15,31 +15,24 @@
|
|||||||
package nathole
|
package nathole
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/stun/v3"
|
"github.com/fatedier/golib/net/stun"
|
||||||
)
|
)
|
||||||
|
|
||||||
var responseTimeout = 3 * time.Second
|
var responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Body []byte
|
|
||||||
Addr string
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the localAddr is empty, it will listen on a random port.
|
// If the localAddr is empty, it will listen on a random port.
|
||||||
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
||||||
// create a discoverConn and get response from messageChan
|
|
||||||
discoverConn, err := listen(localAddr)
|
discoverConn, err := listen(localAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer discoverConn.Close()
|
defer discoverConn.Close()
|
||||||
|
|
||||||
go discoverConn.readLoop()
|
|
||||||
|
|
||||||
addresses := make([]string, 0, len(stunServers))
|
addresses := make([]string, 0, len(stunServers))
|
||||||
for _, addr := range stunServers {
|
for _, addr := range stunServers {
|
||||||
// get external address from stun server
|
// get external address from stun server
|
||||||
@@ -58,10 +51,9 @@ type stunResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type discoverConn struct {
|
type discoverConn struct {
|
||||||
conn *net.UDPConn
|
conn *net.UDPConn
|
||||||
|
client *stun.Client
|
||||||
localAddr net.Addr
|
localAddr net.Addr
|
||||||
messageChan chan *Message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listen(localAddr string) (*discoverConn, error) {
|
func listen(localAddr string) (*discoverConn, error) {
|
||||||
@@ -77,82 +69,50 @@ func listen(localAddr string) (*discoverConn, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
client, err := stun.NewClient(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &discoverConn{
|
return &discoverConn{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
localAddr: conn.LocalAddr(),
|
client: client,
|
||||||
messageChan: make(chan *Message, 10),
|
localAddr: conn.LocalAddr(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *discoverConn) Close() error {
|
func (c *discoverConn) Close() error {
|
||||||
if c.messageChan != nil {
|
|
||||||
close(c.messageChan)
|
|
||||||
c.messageChan = nil
|
|
||||||
}
|
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *discoverConn) readLoop() {
|
|
||||||
for {
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
n, addr, err := c.conn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
buf = buf[:n]
|
|
||||||
|
|
||||||
c.messageChan <- &Message{
|
|
||||||
Body: buf,
|
|
||||||
Addr: addr.String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
|
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
|
||||||
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
transaction, err := stun.NewBindingTransaction(serverAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||||
if err = request.NewTransactionID(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
|
response, err := c.client.Do(transaction)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
var m stun.Message
|
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||||
select {
|
|
||||||
case msg := <-c.messageChan:
|
|
||||||
m.Raw = msg.Body
|
|
||||||
if err := m.Decode(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
case <-time.After(responseTimeout):
|
return nil, err
|
||||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
|
||||||
}
|
}
|
||||||
xorAddrGetter := &stun.XORMappedAddress{}
|
|
||||||
mappedAddrGetter := &stun.MappedAddress{}
|
|
||||||
changedAddrGetter := ChangedAddress{}
|
|
||||||
otherAddrGetter := &stun.OtherAddress{}
|
|
||||||
|
|
||||||
resp := &stunResponse{}
|
resp := &stunResponse{}
|
||||||
if err := mappedAddrGetter.GetFrom(&m); err == nil {
|
if response.MappedAddr != nil {
|
||||||
resp.externalAddr = mappedAddrGetter.String()
|
resp.externalAddr = response.MappedAddr.String()
|
||||||
}
|
}
|
||||||
if err := xorAddrGetter.GetFrom(&m); err == nil {
|
if response.OtherAddr != nil {
|
||||||
resp.externalAddr = xorAddrGetter.String()
|
resp.otherAddr = response.OtherAddr.String()
|
||||||
}
|
|
||||||
if err := changedAddrGetter.GetFrom(&m); err == nil {
|
|
||||||
resp.otherAddr = changedAddrGetter.String()
|
|
||||||
}
|
|
||||||
if err := otherAddrGetter.GetFrom(&m); err == nil {
|
|
||||||
resp.otherAddr = otherAddrGetter.String()
|
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package nathole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatedier/golib/net/stun"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testBindingRequest = 0x0001
|
||||||
|
testBindingSuccess = 0x0101
|
||||||
|
testBindingError = 0x0111
|
||||||
|
testMagicCookie = 0x2112a442
|
||||||
|
testAttrMapped = 0x0001
|
||||||
|
testAttrChanged = 0x0005
|
||||||
|
testAttrErrorCode = 0x0009
|
||||||
|
testAttrXORMapped = 0x0020
|
||||||
|
testAttrOther = 0x802c
|
||||||
|
testSTUNHeaderSize = 20
|
||||||
|
testSTUNServerLimit = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type testSTUNAttribute struct {
|
||||||
|
typ uint16
|
||||||
|
value []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSTUNExchange struct {
|
||||||
|
source *net.UDPAddr
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenTestUDP4(t *testing.T) *net.UDPConn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveOneSTUNRequest(
|
||||||
|
server *net.UDPConn,
|
||||||
|
buildResponse func([]byte, *net.UDPAddr) ([]byte, error),
|
||||||
|
) <-chan testSTUNExchange {
|
||||||
|
done := make(chan testSTUNExchange, 1)
|
||||||
|
go func() {
|
||||||
|
if err := server.SetDeadline(time.Now().Add(testSTUNServerLimit)); err != nil {
|
||||||
|
done <- testSTUNExchange{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
n, source, err := server.ReadFromUDP(buffer)
|
||||||
|
if err == nil && buildResponse != nil {
|
||||||
|
var response []byte
|
||||||
|
response, err = buildResponse(buffer[:n], source)
|
||||||
|
if err == nil && response != nil {
|
||||||
|
_, err = server.WriteToUDP(response, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- testSTUNExchange{source: source, err: err}
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitSTUNExchange(t *testing.T, done <-chan testSTUNExchange) *net.UDPAddr {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case exchange := <-done:
|
||||||
|
require.NoError(t, exchange.err)
|
||||||
|
return exchange.source
|
||||||
|
case <-time.After(testSTUNServerLimit):
|
||||||
|
t.Fatal("timed out waiting for local STUN server")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestSTUNResponse(request []byte, typ uint16, attributes ...testSTUNAttribute) ([]byte, error) {
|
||||||
|
if len(request) != testSTUNHeaderSize || binary.BigEndian.Uint16(request[0:2]) != testBindingRequest ||
|
||||||
|
binary.BigEndian.Uint32(request[4:8]) != testMagicCookie {
|
||||||
|
return nil, fmt.Errorf("invalid Binding request")
|
||||||
|
}
|
||||||
|
|
||||||
|
length := 0
|
||||||
|
for _, attribute := range attributes {
|
||||||
|
length += 4 + (len(attribute.value)+3)&^3
|
||||||
|
}
|
||||||
|
response := make([]byte, testSTUNHeaderSize, testSTUNHeaderSize+length)
|
||||||
|
binary.BigEndian.PutUint16(response[0:2], typ)
|
||||||
|
binary.BigEndian.PutUint16(response[2:4], uint16(length))
|
||||||
|
binary.BigEndian.PutUint32(response[4:8], testMagicCookie)
|
||||||
|
copy(response[8:20], request[8:20])
|
||||||
|
|
||||||
|
for _, attribute := range attributes {
|
||||||
|
start := len(response)
|
||||||
|
paddedLength := (len(attribute.value) + 3) &^ 3
|
||||||
|
response = append(response, make([]byte, 4+paddedLength)...)
|
||||||
|
binary.BigEndian.PutUint16(response[start:start+2], attribute.typ)
|
||||||
|
binary.BigEndian.PutUint16(response[start+2:start+4], uint16(len(attribute.value)))
|
||||||
|
copy(response[start+4:], attribute.value)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIPv4AddressValue(ip net.IP, port int, xor bool) []byte {
|
||||||
|
value := make([]byte, 8)
|
||||||
|
value[1] = 0x01
|
||||||
|
binary.BigEndian.PutUint16(value[2:4], uint16(port))
|
||||||
|
copy(value[4:], ip.To4())
|
||||||
|
if xor {
|
||||||
|
binary.BigEndian.PutUint16(value[2:4], binary.BigEndian.Uint16(value[2:4])^uint16(testMagicCookie>>16))
|
||||||
|
for i := range 4 {
|
||||||
|
value[4+i] ^= byte(uint32(testMagicCookie) >> uint(24-8*i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverReusesLocalPortAndPreservesNATClassification(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secondMapped string
|
||||||
|
secondMappedPort int
|
||||||
|
wantNATType string
|
||||||
|
wantBehavior string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same mapped address",
|
||||||
|
secondMapped: "198.51.100.10:40000",
|
||||||
|
secondMappedPort: 40000,
|
||||||
|
wantNATType: EasyNAT,
|
||||||
|
wantBehavior: BehaviorNoChange,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different mapped port",
|
||||||
|
secondMapped: "198.51.100.10:40001",
|
||||||
|
secondMappedPort: 40001,
|
||||||
|
wantNATType: HardNAT,
|
||||||
|
wantBehavior: BehaviorPortChanged,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
primary := listenTestUDP4(t)
|
||||||
|
alternate := listenTestUDP4(t)
|
||||||
|
alternateAddr := alternate.LocalAddr().(*net.UDPAddr)
|
||||||
|
|
||||||
|
primaryDone := serveOneSTUNRequest(primary, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), 40000, true)},
|
||||||
|
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(alternateAddr.IP, alternateAddr.Port, false)},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
alternateDone := serveOneSTUNRequest(alternate, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), tt.secondMappedPort, true)},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
addresses, localAddr, err := Discover([]string{primary.LocalAddr().String()}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"198.51.100.10:40000", tt.secondMapped}, addresses)
|
||||||
|
|
||||||
|
primarySource := waitSTUNExchange(t, primaryDone)
|
||||||
|
alternateSource := waitSTUNExchange(t, alternateDone)
|
||||||
|
require.Equal(t, primarySource.Port, alternateSource.Port)
|
||||||
|
require.Equal(t, localAddr.(*net.UDPAddr).Port, primarySource.Port)
|
||||||
|
|
||||||
|
feature, err := ClassifyNATFeature(addresses, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.wantNATType, feature.NatType)
|
||||||
|
require.Equal(t, tt.wantBehavior, feature.Behavior)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoSTUNRequestMapsLegacyAndModernAddresses(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
attributes []testSTUNAttribute
|
||||||
|
wantExternal string
|
||||||
|
wantOther string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "legacy",
|
||||||
|
attributes: []testSTUNAttribute{
|
||||||
|
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||||
|
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||||
|
},
|
||||||
|
wantExternal: "192.0.2.1:1000",
|
||||||
|
wantOther: "192.0.2.2:2000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modern takes precedence",
|
||||||
|
attributes: []testSTUNAttribute{
|
||||||
|
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||||
|
{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||||
|
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||||
|
{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("198.51.100.2"), 4000, false)},
|
||||||
|
},
|
||||||
|
wantExternal: "198.51.100.1:3000",
|
||||||
|
wantOther: "198.51.100.2:4000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess, tt.attributes...)
|
||||||
|
})
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.wantExternal, response.externalAddr)
|
||||||
|
require.Equal(t, tt.wantOther, response.otherAddr)
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTUNResponseErrorsAndMissingAddresses(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
buildResponse func([]byte, *net.UDPAddr) ([]byte, error)
|
||||||
|
request func(*discoverConn, string) error
|
||||||
|
checkError func(*testing.T, error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "correlated malformed response",
|
||||||
|
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
response, err := makeTestSTUNResponse(request, testBindingSuccess)
|
||||||
|
if err == nil {
|
||||||
|
binary.BigEndian.PutUint16(response[2:4], 4)
|
||||||
|
}
|
||||||
|
return response, err
|
||||||
|
},
|
||||||
|
request: func(conn *discoverConn, server string) error {
|
||||||
|
_, err := conn.doSTUNRequest(server)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
checkError: func(t *testing.T, err error) {
|
||||||
|
require.ErrorIs(t, err, stun.ErrMalformedResponse)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Binding error response",
|
||||||
|
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingError, testSTUNAttribute{
|
||||||
|
typ: testAttrErrorCode,
|
||||||
|
value: []byte{0, 0, 4, 20, 'U', 'n', 'k', 'n', 'o', 'w', 'n'},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
request: func(conn *discoverConn, server string) error {
|
||||||
|
_, err := conn.doSTUNRequest(server)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
checkError: func(t *testing.T, err error) {
|
||||||
|
var responseErr *stun.ResponseError
|
||||||
|
require.ErrorAs(t, err, &responseErr)
|
||||||
|
require.Equal(t, 420, responseErr.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing mapped address",
|
||||||
|
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
request: func(conn *discoverConn, server string) error {
|
||||||
|
_, err := conn.discoverFromStunServer(server)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
checkError: func(t *testing.T, err error) {
|
||||||
|
require.EqualError(t, err, "no external address found")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, tt.buildResponse)
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
err = tt.request(conn, server.LocalAddr().String())
|
||||||
|
tt.checkError(t, err)
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing other address", func(t *testing.T) {
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := Prepare([]string{server.LocalAddr().String()}, PrepareOptions{})
|
||||||
|
require.EqualError(t, err, "discover error: not enough addresses")
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTUNTimeoutUsesCallerDeadlineWithoutRetry(t *testing.T) {
|
||||||
|
originalTimeout := responseTimeout
|
||||||
|
responseTimeout = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||||
|
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, nil)
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
_, err = conn.doSTUNRequest(server.LocalAddr().String())
|
||||||
|
require.EqualError(t, err, "wait response from stun server timeout")
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
|
||||||
|
require.NoError(t, server.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
|
||||||
|
_, _, err = server.ReadFromUDP(make([]byte, 1))
|
||||||
|
var netErr net.Error
|
||||||
|
require.ErrorAs(t, err, &netErr)
|
||||||
|
require.True(t, netErr.Timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTUNClientLeavesSocketAndDeadlineWithCaller(t *testing.T) {
|
||||||
|
originalTimeout := responseTimeout
|
||||||
|
responseTimeout = 100 * time.Millisecond
|
||||||
|
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||||
|
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
unrelated := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, func(request []byte, source *net.UDPAddr) ([]byte, error) {
|
||||||
|
response, err := makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.5"), 5000, true)},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := unrelated.WriteToUDP(response, source); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
})
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "198.51.100.5:5000", response.externalAddr)
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
|
||||||
|
_, _, err = conn.conn.ReadFromUDP(make([]byte, 1))
|
||||||
|
var netErr net.Error
|
||||||
|
require.True(t, errors.As(err, &netErr))
|
||||||
|
require.True(t, netErr.Timeout())
|
||||||
|
|
||||||
|
require.NoError(t, conn.conn.SetDeadline(time.Time{}))
|
||||||
|
require.NoError(t, server.SetReadDeadline(time.Now().Add(testSTUNServerLimit)))
|
||||||
|
_, err = conn.conn.WriteToUDP([]byte{1}, server.LocalAddr().(*net.UDPAddr))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, source, err := server.ReadFromUDP(make([]byte, 1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, conn.localAddr.(*net.UDPAddr).Port, source.Port)
|
||||||
|
}
|
||||||
@@ -18,10 +18,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/pion/stun/v3"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
)
|
)
|
||||||
@@ -48,20 +46,6 @@ func DecodeMessageInto(data, key []byte, m msg.Message) error {
|
|||||||
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangedAddress struct {
|
|
||||||
IP net.IP
|
|
||||||
Port int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChangedAddress) GetFrom(m *stun.Message) error {
|
|
||||||
a := (*stun.MappedAddress)(s)
|
|
||||||
return a.GetFromAs(m, stun.AttrChangedAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChangedAddress) String() string {
|
|
||||||
return net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListAllLocalIPs() ([]net.IP, error) {
|
func ListAllLocalIPs() ([]net.IP, error) {
|
||||||
addrs, err := net.InterfaceAddrs()
|
addrs, err := net.InterfaceAddrs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
// Copyright 2026 The LoliaTeam Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
//go:build !frps
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
|
||||||
"golang.org/x/crypto/acme/autocert"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
|
|
||||||
if auto == nil || !auto.Enable {
|
|
||||||
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
|
|
||||||
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostSet := make(map[string]struct{})
|
|
||||||
hosts := make([]string, 0, len(auto.HostAllowList))
|
|
||||||
addHost := func(host string) {
|
|
||||||
host = strings.TrimSpace(strings.ToLower(host))
|
|
||||||
if host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.Contains(host, "*") {
|
|
||||||
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := hostSet[host]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hostSet[host] = struct{}{}
|
|
||||||
hosts = append(hosts, host)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range auto.HostAllowList {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
if len(hosts) == 0 {
|
|
||||||
for _, host := range fallbackHosts {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(hosts) == 0 {
|
|
||||||
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &autocert.Manager{
|
|
||||||
Prompt: autocert.AcceptTOS,
|
|
||||||
Email: strings.TrimSpace(auto.Email),
|
|
||||||
HostPolicy: autocert.HostWhitelist(hosts...),
|
|
||||||
}
|
|
||||||
caDirURL := strings.TrimSpace(auto.CADirURL)
|
|
||||||
if caDirURL != "" {
|
|
||||||
manager.Client = &acme.Client{DirectoryURL: caDirURL}
|
|
||||||
} else {
|
|
||||||
caDirURL = autocert.DefaultACMEDirectory
|
|
||||||
}
|
|
||||||
managedHosts := make(map[string]struct{}, len(hosts))
|
|
||||||
for _, host := range hosts {
|
|
||||||
managedHosts[host] = struct{}{}
|
|
||||||
}
|
|
||||||
var warmupInProgress sync.Map
|
|
||||||
var warmupMissLogged sync.Map
|
|
||||||
manager.Cache = &autoTLSCache{
|
|
||||||
inner: autocert.DirCache(auto.CacheDir),
|
|
||||||
managedHosts: managedHosts,
|
|
||||||
pluginName: pluginName,
|
|
||||||
caDirURL: caDirURL,
|
|
||||||
warmupInProgress: &warmupInProgress,
|
|
||||||
warmupMissLogged: &warmupMissLogged,
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := manager.TLSConfig()
|
|
||||||
log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir)
|
|
||||||
|
|
||||||
var readySeen sync.Map
|
|
||||||
|
|
||||||
handleCertReady := func(host string, cert *tls.Certificate) {
|
|
||||||
var (
|
|
||||||
notAfter time.Time
|
|
||||||
hasExpiry bool
|
|
||||||
)
|
|
||||||
if t, ok := getCertificateNotAfter(cert); ok {
|
|
||||||
notAfter = t
|
|
||||||
hasExpiry = true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
|
|
||||||
if hasExpiry {
|
|
||||||
if !readyLogged {
|
|
||||||
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
} else if !readyLogged {
|
|
||||||
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
|
|
||||||
if host == "" {
|
|
||||||
host = "<空SNI>"
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := manager.GetCertificate(hello)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
handleCertReady(host, cert)
|
|
||||||
return cert, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warm up certificates in background after startup.
|
|
||||||
for _, host := range hosts {
|
|
||||||
h := host
|
|
||||||
go func() {
|
|
||||||
// Leave time for listener setup and route registration.
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
warmupMissLogged.Delete(h)
|
|
||||||
warmupInProgress.Store(h, struct{}{})
|
|
||||||
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
|
|
||||||
warmupInProgress.Delete(h)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleCertReady(h, cert)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
|
|
||||||
if cert == nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
if cert.Leaf != nil {
|
|
||||||
return cert.Leaf.NotAfter, true
|
|
||||||
}
|
|
||||||
if len(cert.Certificate) == 0 {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return leaf.NotAfter, true
|
|
||||||
}
|
|
||||||
|
|
||||||
type autoTLSCache struct {
|
|
||||||
inner autocert.Cache
|
|
||||||
managedHosts map[string]struct{}
|
|
||||||
pluginName string
|
|
||||||
caDirURL string
|
|
||||||
warmupInProgress *sync.Map
|
|
||||||
warmupMissLogged *sync.Map
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
|
|
||||||
data, err := c.inner.Get(ctx, key)
|
|
||||||
if err != autocert.ErrCacheMiss {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := strings.TrimSuffix(key, "+rsa")
|
|
||||||
if _, ok := c.managedHosts[host]; !ok {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
if _, warming := c.warmupInProgress.Load(host); !warming {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
|
|
||||||
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-01,caDirURL=%s", c.pluginName, host, c.caDirURL)
|
|
||||||
}
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *autoTLSCache) Put(ctx context.Context, key string, data []byte) error {
|
|
||||||
return c.inner.Put(ctx, key, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *autoTLSCache) Delete(ctx context.Context, key string) error {
|
|
||||||
return c.inner.Delete(ctx, key)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Copyright 2026 The LoliaTeam Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
//go:build !frps
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTP2HTTPSRedirectPlugin struct {
|
|
||||||
opts *v1.HTTP2HTTPSRedirectPluginOptions
|
|
||||||
|
|
||||||
l *Listener
|
|
||||||
s *http.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
|
||||||
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
|
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
p := &HTTP2HTTPSRedirectPlugin{
|
|
||||||
opts: opts,
|
|
||||||
l: listener,
|
|
||||||
}
|
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
||||||
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound)
|
|
||||||
}),
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
|
|
||||||
host := strings.TrimSpace(req.Host)
|
|
||||||
if host == "" {
|
|
||||||
host = strings.TrimSpace(req.URL.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetHost := host
|
|
||||||
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
|
|
||||||
targetHost = parsedHost
|
|
||||||
if httpsPort == 0 && parsedPort == "443" {
|
|
||||||
httpsPort = 443
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
||||||
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
|
||||||
}
|
|
||||||
|
|
||||||
if httpsPort != 0 && httpsPort != 443 {
|
|
||||||
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (&url.URL{
|
|
||||||
Scheme: "https",
|
|
||||||
Host: targetHost,
|
|
||||||
Path: req.URL.Path,
|
|
||||||
RawPath: req.URL.RawPath,
|
|
||||||
RawQuery: req.URL.RawQuery,
|
|
||||||
}).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
if connInfo.SrcAddr != nil {
|
|
||||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
|
||||||
}
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
|
|
||||||
return v1.PluginHTTP2HTTPSRedirect
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
stdlog "log"
|
stdlog "log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -81,27 +80,11 @@ func newHTTPSBridgePluginServer(
|
|||||||
enableHTTP2 *bool,
|
enableHTTP2 *bool,
|
||||||
useSourceRemoteAddr bool,
|
useSourceRemoteAddr bool,
|
||||||
) (*httpBridgePlugin, error) {
|
) (*httpBridgePlugin, error) {
|
||||||
|
listener := NewProxyListener()
|
||||||
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
|
|
||||||
// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
|
|
||||||
func newHTTPSBridgePluginServerWithTLSConfig(
|
|
||||||
handler http.Handler,
|
|
||||||
tlsConfig *tls.Config,
|
|
||||||
enableHTTP2 *bool,
|
|
||||||
useSourceRemoteAddr bool,
|
|
||||||
) *httpBridgePlugin {
|
|
||||||
server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
|
|
||||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
|
|
||||||
listener := NewProxyListener()
|
|
||||||
p := &httpBridgePlugin{
|
p := &httpBridgePlugin{
|
||||||
l: listener,
|
l: listener,
|
||||||
s: server,
|
s: server,
|
||||||
@@ -110,7 +93,7 @@ func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool
|
|||||||
go func() {
|
go func() {
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
_ = p.s.ServeTLS(listener, "", "")
|
||||||
}()
|
}()
|
||||||
return p
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHTTPBridgeReverseProxy(
|
func newHTTPBridgeReverseProxy(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
@@ -33,7 +32,7 @@ type HTTPS2HTTPPlugin struct {
|
|||||||
*httpBridgePlugin
|
*httpBridgePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||||
|
|
||||||
p := &HTTPS2HTTPPlugin{
|
p := &HTTPS2HTTPPlugin{
|
||||||
@@ -50,15 +49,6 @@ func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions
|
|||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
|
||||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
|
||||||
}
|
|
||||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ type HTTPS2HTTPSPlugin struct {
|
|||||||
*httpBridgePlugin
|
*httpBridgePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||||
|
|
||||||
p := &HTTPS2HTTPSPlugin{
|
p := &HTTPS2HTTPSPlugin{
|
||||||
@@ -56,15 +55,6 @@ func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOption
|
|||||||
tr,
|
tr,
|
||||||
)
|
)
|
||||||
|
|
||||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
|
||||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
|
||||||
}
|
|
||||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -33,12 +33,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||||
}
|
}
|
||||||
return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
|
|
||||||
// It is used by features such as autoTLS that supply their own certificate provider.
|
|
||||||
func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: withMisdirectedRequestCheck(handler),
|
Handler: withMisdirectedRequestCheck(handler),
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
@@ -47,7 +42,7 @@ func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *
|
|||||||
if !lo.FromPtr(enableHTTP2) {
|
if !lo.FromPtr(enableHTTP2) {
|
||||||
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
||||||
}
|
}
|
||||||
return server
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import (
|
|||||||
|
|
||||||
type PluginContext struct {
|
type PluginContext struct {
|
||||||
Name string
|
Name string
|
||||||
HostAllowList []string
|
|
||||||
VnetController *vnet.Controller
|
VnetController *vnet.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,25 +39,16 @@ type TLS2RawPlugin struct {
|
|||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.TLS2RawPluginOptions)
|
opts := options.(*v1.TLS2RawPluginOptions)
|
||||||
|
|
||||||
p := &TLS2RawPlugin{
|
p := &TLS2RawPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||||
var err error
|
if err != nil {
|
||||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
return nil, err
|
||||||
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
p.tlsConfig = tlsConfig
|
p.tlsConfig = tlsConfig
|
||||||
return p, nil
|
return p, nil
|
||||||
@@ -81,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if connInfo.ProxyProtocolHeader != nil {
|
||||||
|
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||||
|
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||||
|
rawConn.Close()
|
||||||
|
tlsConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
libio.Join(tlsConn, rawConn)
|
libio.Join(tlsConn, rawConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
charmlog "github.com/charmbracelet/log"
|
goliblog "github.com/fatedier/golib/log"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
frplog "github.com/fatedier/frp/pkg/util/log"
|
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||||
@@ -39,8 +39,8 @@ type testPlugin struct {
|
|||||||
var logCaptureMu sync.Mutex
|
var logCaptureMu sync.Mutex
|
||||||
|
|
||||||
type logCapture struct {
|
type logCapture struct {
|
||||||
mu sync.Mutex
|
bytes.Buffer
|
||||||
buf bytes.Buffer
|
levels []goliblog.Level
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p testPlugin) Name() string {
|
func (p testPlugin) Name() string {
|
||||||
@@ -55,36 +55,9 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
|
|||||||
return p.handler(ctx, op, content)
|
return p.handler(ctx, op, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *logCapture) Write(p []byte) (int, error) {
|
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
||||||
w.mu.Lock()
|
w.levels = append(w.levels, level)
|
||||||
defer w.mu.Unlock()
|
return w.Write(p)
|
||||||
return w.buf.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *logCapture) String() string {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
return w.buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// levels parses the captured JSON log output and returns the level of each entry.
|
|
||||||
func (w *logCapture) levels() []charmlog.Level {
|
|
||||||
var levels []charmlog.Level
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(w.String()), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var entry struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
|
|
||||||
levels = append(levels, lvl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return levels
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureLogOutput(t *testing.T) *logCapture {
|
func captureLogOutput(t *testing.T) *logCapture {
|
||||||
@@ -93,10 +66,11 @@ func captureLogOutput(t *testing.T) *logCapture {
|
|||||||
logCaptureMu.Lock()
|
logCaptureMu.Lock()
|
||||||
logOutput := &logCapture{}
|
logOutput := &logCapture{}
|
||||||
oldLogger := frplog.Logger
|
oldLogger := frplog.Logger
|
||||||
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
|
frplog.Logger = goliblog.New(
|
||||||
Level: charmlog.DebugLevel,
|
goliblog.WithOutput(logOutput),
|
||||||
Formatter: charmlog.JSONFormatter,
|
goliblog.WithLevel(goliblog.TraceLevel),
|
||||||
})
|
goliblog.WithCaller(false),
|
||||||
|
)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
frplog.Logger = oldLogger
|
frplog.Logger = oldLogger
|
||||||
logCaptureMu.Unlock()
|
logCaptureMu.Unlock()
|
||||||
@@ -289,10 +263,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
op string
|
op string
|
||||||
level charmlog.Level
|
level goliblog.Level
|
||||||
}{
|
}{
|
||||||
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
|
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
||||||
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
|
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -314,9 +288,8 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
|||||||
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
levels := logOutput.levels()
|
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
||||||
if len(levels) != 1 || levels[0] != tt.level {
|
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
||||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -352,13 +325,12 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
||||||
t.Fatalf("missing aggregated errors: %v", err)
|
t.Fatalf("missing aggregated errors: %v", err)
|
||||||
}
|
}
|
||||||
levels := logOutput.levels()
|
if len(logOutput.levels) != 2 {
|
||||||
if len(levels) != 2 {
|
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
||||||
t.Fatalf("expected two warning logs, got %v", levels)
|
|
||||||
}
|
}
|
||||||
for _, level := range levels {
|
for _, level := range logOutput.levels {
|
||||||
if level != charmlog.WarnLevel {
|
if level != goliblog.WarnLevel {
|
||||||
t.Fatalf("expected warning log level, got %v", levels)
|
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -16,7 +16,6 @@ package ssh
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -52,6 +51,11 @@ type tcpipForward struct {
|
|||||||
Port uint32
|
Port uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4254#section-6.5
|
||||||
|
type execPayload struct {
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4254#page-16
|
// https://datatracker.ietf.org/doc/html/rfc4254#page-16
|
||||||
type forwardedTCPPayload struct {
|
type forwardedTCPPayload struct {
|
||||||
Addr string
|
Addr string
|
||||||
@@ -309,14 +313,13 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
|||||||
if req.WantReply {
|
if req.WantReply {
|
||||||
_ = req.Reply(true, nil)
|
_ = req.Reply(true, nil)
|
||||||
}
|
}
|
||||||
if req.Type != "exec" || len(req.Payload) <= 4 {
|
if req.Type != "exec" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
end := 4 + binary.BigEndian.Uint32(req.Payload[:4])
|
extraPayload, ok := parseExecPayload(req.Payload)
|
||||||
if len(req.Payload) < int(end) {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
extraPayload := string(req.Payload[4:end])
|
|
||||||
select {
|
select {
|
||||||
case extraPayloadCh <- extraPayload:
|
case extraPayloadCh <- extraPayload:
|
||||||
default:
|
default:
|
||||||
@@ -324,6 +327,14 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseExecPayload(payload []byte) (string, bool) {
|
||||||
|
var msg execPayload
|
||||||
|
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return msg.Command, true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TunnelServer) keepAlive(ch ssh.Channel) {
|
func (s *TunnelServer) keepAlive(ch ssh.Channel) {
|
||||||
tk := time.NewTicker(time.Second * 30)
|
tk := time.NewTicker(time.Second * 30)
|
||||||
defer tk.Stop()
|
defer tk.Stop()
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
cryptossh "golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseExecPayload(t *testing.T) {
|
||||||
|
payload := cryptossh.Marshal(&execPayload{Command: "tcp --remote_port 6000"})
|
||||||
|
|
||||||
|
got, ok := parseExecPayload(payload)
|
||||||
|
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "tcp --remote_port 6000", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) {
|
||||||
|
overflowLength := make([]byte, 5)
|
||||||
|
binary.BigEndian.PutUint32(overflowLength[:4], ^uint32(0))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
payload: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short length prefix",
|
||||||
|
payload: []byte{0, 0, 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared length exceeds remaining payload",
|
||||||
|
payload: []byte{0, 0, 0, 2, 'x'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overflow length",
|
||||||
|
payload: overflowLength,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var (
|
||||||
|
got string
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
got, ok = parseExecPayload(tc.payload)
|
||||||
|
})
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Empty(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package banner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DisplayBanner() {
|
|
||||||
fmt.Println(" __ ___ __________ ____ ________ ____")
|
|
||||||
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
|
|
||||||
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
|
|
||||||
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
|
|
||||||
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
|
|
||||||
fmt.Println(" ")
|
|
||||||
log.Infof("Nya! %s 启动中", version.Full())
|
|
||||||
}
|
|
||||||
+27
-158
@@ -16,18 +16,13 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/fatedier/golib/log"
|
||||||
"github.com/charmbracelet/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TraceLevel = log.DebugLevel
|
TraceLevel = log.TraceLevel
|
||||||
DebugLevel = log.DebugLevel
|
DebugLevel = log.DebugLevel
|
||||||
InfoLevel = log.InfoLevel
|
InfoLevel = log.InfoLevel
|
||||||
WarnLevel = log.WarnLevel
|
WarnLevel = log.WarnLevel
|
||||||
@@ -37,158 +32,39 @@ var (
|
|||||||
var Logger *log.Logger
|
var Logger *log.Logger
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Logger = log.NewWithOptions(os.Stderr, log.Options{
|
Logger = log.New(
|
||||||
ReportCaller: true,
|
log.WithCaller(true),
|
||||||
ReportTimestamp: true,
|
log.AddCallerSkip(1),
|
||||||
TimeFormat: time.Kitchen,
|
log.WithLevel(log.InfoLevel),
|
||||||
Prefix: "LoliaFRP-CLI",
|
)
|
||||||
CallerOffset: 1,
|
|
||||||
})
|
|
||||||
// 设置自定义样式以支持 Trace 级别
|
|
||||||
styles := log.DefaultStyles()
|
|
||||||
styles.Levels[TraceLevel] = lipgloss.NewStyle().
|
|
||||||
SetString("TRACE").
|
|
||||||
Bold(true).
|
|
||||||
MaxWidth(5).
|
|
||||||
Foreground(lipgloss.Color("61"))
|
|
||||||
Logger.SetStyles(styles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
||||||
var output io.Writer
|
options := []log.Option{}
|
||||||
var err error
|
|
||||||
|
|
||||||
if logPath == "console" {
|
if logPath == "console" {
|
||||||
output = os.Stdout
|
if !disableLogColor {
|
||||||
} else {
|
options = append(options,
|
||||||
// Use rotating file writer
|
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
|
||||||
output, err = NewRotateFileWriter(logPath, maxDays)
|
Colorful: true,
|
||||||
if err != nil {
|
}, os.Stdout)),
|
||||||
// Fallback to console if file creation fails
|
)
|
||||||
output = os.Stdout
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
writer := log.NewRotateFileWriter(log.RotateFileConfig{
|
||||||
|
FileName: logPath,
|
||||||
|
Mode: log.RotateFileModeDaily,
|
||||||
|
MaxDays: maxDays,
|
||||||
|
})
|
||||||
|
writer.Init()
|
||||||
|
options = append(options, log.WithOutput(writer))
|
||||||
}
|
}
|
||||||
|
|
||||||
level, err := log.ParseLevel(levelStr)
|
level, err := log.ParseLevel(levelStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
level = log.InfoLevel
|
level = log.InfoLevel
|
||||||
}
|
}
|
||||||
|
options = append(options, log.WithLevel(level))
|
||||||
Logger = log.NewWithOptions(output, log.Options{
|
Logger = Logger.WithOptions(options...)
|
||||||
ReportCaller: true,
|
|
||||||
ReportTimestamp: true,
|
|
||||||
TimeFormat: time.Kitchen,
|
|
||||||
Prefix: "LoliaFRP-CLI",
|
|
||||||
CallerOffset: 1,
|
|
||||||
Level: level,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRotateFileWriter creates a rotating file writer
|
|
||||||
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
|
|
||||||
w := &RotateFileWriter{
|
|
||||||
filePath: filePath,
|
|
||||||
maxDays: maxDays,
|
|
||||||
lastRotate: time.Now(),
|
|
||||||
currentDate: time.Now().Format("2006-01-02"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.openFile(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotateFileWriter implements io.Writer with daily rotation
|
|
||||||
type RotateFileWriter struct {
|
|
||||||
filePath string
|
|
||||||
maxDays int
|
|
||||||
file *os.File
|
|
||||||
lastRotate time.Time
|
|
||||||
currentDate string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) openFile() error {
|
|
||||||
var err error
|
|
||||||
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) checkRotate() error {
|
|
||||||
now := time.Now()
|
|
||||||
currentDate := now.Format("2006-01-02")
|
|
||||||
|
|
||||||
if currentDate != w.currentDate {
|
|
||||||
// Close current file
|
|
||||||
if w.file != nil {
|
|
||||||
w.file.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename current file with date suffix
|
|
||||||
oldPath := w.filePath
|
|
||||||
newPath := w.filePath + "." + w.currentDate
|
|
||||||
if _, err := os.Stat(oldPath); err == nil {
|
|
||||||
if err := os.Rename(oldPath, newPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old log files
|
|
||||||
w.cleanupOldLogs(now)
|
|
||||||
|
|
||||||
// Update current date and open new file
|
|
||||||
w.currentDate = currentDate
|
|
||||||
w.lastRotate = now
|
|
||||||
return w.openFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
|
|
||||||
if w.maxDays <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoffDate := now.AddDate(0, 0, -w.maxDays)
|
|
||||||
|
|
||||||
// Find and remove old log files
|
|
||||||
dir := filepath.Dir(w.filePath)
|
|
||||||
base := filepath.Base(w.filePath)
|
|
||||||
|
|
||||||
files, _ := os.ReadDir(dir)
|
|
||||||
for _, f := range files {
|
|
||||||
if f.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := f.Name()
|
|
||||||
if strings.HasPrefix(name, base+".") {
|
|
||||||
// Extract date from filename (base.YYYY-MM-DD)
|
|
||||||
dateStr := strings.TrimPrefix(name, base+".")
|
|
||||||
if len(dateStr) == 10 {
|
|
||||||
fileDate, err := time.Parse("2006-01-02", dateStr)
|
|
||||||
if err == nil && fileDate.Before(cutoffDate) {
|
|
||||||
os.Remove(filepath.Join(dir, name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) Write(p []byte) (n int, err error) {
|
|
||||||
if err := w.checkRotate(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return w.file.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) Close() error {
|
|
||||||
if w.file != nil {
|
|
||||||
return w.file.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Errorf(format string, v ...any) {
|
func Errorf(format string, v ...any) {
|
||||||
@@ -199,10 +75,6 @@ func Warnf(format string, v ...any) {
|
|||||||
Logger.Warnf(format, v...)
|
Logger.Warnf(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Info(format string, v ...any) {
|
|
||||||
Logger.Info(format, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Infof(format string, v ...any) {
|
func Infof(format string, v ...any) {
|
||||||
Logger.Infof(format, v...)
|
Logger.Infof(format, v...)
|
||||||
}
|
}
|
||||||
@@ -212,12 +84,11 @@ func Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Tracef(format string, v ...any) {
|
func Tracef(format string, v ...any) {
|
||||||
Logger.Logf(TraceLevel, format, v...)
|
Logger.Tracef(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logf(level log.Level, offset int, format string, v ...any) {
|
func Logf(level log.Level, offset int, format string, v ...any) {
|
||||||
// charmbracelet/log doesn't support offset, so we ignore it
|
Logger.Logf(level, offset, format, v...)
|
||||||
Logger.Logf(level, format, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteLogger struct {
|
type WriteLogger struct {
|
||||||
@@ -233,8 +104,6 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
||||||
// charmbracelet/log doesn't support offset in Log
|
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
|
||||||
msg := string(bytes.TrimRight(p, "\n"))
|
|
||||||
Logger.Log(w.level, msg)
|
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package net
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hkdf"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -25,7 +26,6 @@ import (
|
|||||||
|
|
||||||
libcrypto "github.com/fatedier/golib/crypto"
|
libcrypto "github.com/fatedier/golib/crypto"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"golang.org/x/crypto/hkdf"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
)
|
)
|
||||||
@@ -335,11 +335,6 @@ func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
||||||
info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
|
info := aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction
|
||||||
reader := hkdf.New(sha256.New, key, transcriptHash, info)
|
return hkdf.Key(sha256.New, key, transcriptHash, info, libcrypto.AEADKeySize)
|
||||||
out := make([]byte, libcrypto.AEADKeySize)
|
|
||||||
if _, err := io.ReadFull(reader, out); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,5 +114,11 @@ func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
|
|||||||
bytes.Repeat([]byte{0x44}, 32),
|
bytes.Repeat([]byte{0x44}, 32),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []byte{
|
||||||
|
0xa0, 0x58, 0xcd, 0x02, 0x5d, 0x96, 0x98, 0x5f,
|
||||||
|
0xeb, 0xeb, 0xff, 0x79, 0xa1, 0x9f, 0x62, 0xb7,
|
||||||
|
0x15, 0xe0, 0x53, 0x91, 0x3d, 0xfc, 0x74, 0x77,
|
||||||
|
0x05, 0x91, 0x4c, 0x62, 0x4b, 0xf3, 0xd4, 0x95,
|
||||||
|
}, clientToServerKey)
|
||||||
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||||
|
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||||
|
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||||
|
// frames and close the connection on invalid bytes.
|
||||||
|
conn.PayloadType = websocket.BinaryFrame
|
||||||
return ctx, conn, nil
|
return ctx, conn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,9 @@ package net
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetDefaultDNSAddress(dnsAddress string) {
|
func SetDefaultDNSAddress(dnsAddress string) {
|
||||||
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
|
|
||||||
if strings.HasPrefix(dnsAddress, "https://") {
|
|
||||||
SetDefaultDNSOverHTTPS(dnsAddress)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
||||||
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
// Copyright 2026 The Lolia Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package net
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
dohMimeType = "application/dns-message"
|
|
||||||
dohMaxResponseSize = 65535
|
|
||||||
dohRequestTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends
|
|
||||||
// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint,
|
|
||||||
// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query".
|
|
||||||
func SetDefaultDNSOverHTTPS(dohURL string) {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: dohRequestTimeout,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: dohRequestTimeout,
|
|
||||||
// Use a fresh resolver to look up the DoH server hostname itself,
|
|
||||||
// avoiding infinite recursion through net.DefaultResolver.
|
|
||||||
Resolver: &net.Resolver{},
|
|
||||||
}).DialContext,
|
|
||||||
MaxIdleConns: 10,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
|
||||||
ForceAttemptHTTP2: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
net.DefaultResolver = &net.Resolver{
|
|
||||||
PreferGo: true,
|
|
||||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
||||||
return &dohConn{
|
|
||||||
ctx: ctx,
|
|
||||||
client: client,
|
|
||||||
url: dohURL,
|
|
||||||
network: network,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dohConn adapts the DNS wire-format messages exchanged by net.Resolver
|
|
||||||
// into DNS-over-HTTPS requests. Since dohConn does not implement
|
|
||||||
// net.PacketConn, the resolver always uses stream (TCP-style) framing with a
|
|
||||||
// 2-byte big-endian length prefix, regardless of the dialed network. The
|
|
||||||
// resolver writes a query and then reads the response from the same
|
|
||||||
// connection; the HTTP round trip happens synchronously inside Write.
|
|
||||||
type dohConn struct {
|
|
||||||
ctx context.Context
|
|
||||||
client *http.Client
|
|
||||||
url string
|
|
||||||
network string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
deadline time.Time
|
|
||||||
reqBuf bytes.Buffer // unprocessed request bytes
|
|
||||||
respBuf bytes.Buffer // response stream with 2-byte length prefixes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) Write(b []byte) (int, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.reqBuf.Write(b)
|
|
||||||
for {
|
|
||||||
data := c.reqBuf.Bytes()
|
|
||||||
if len(data) < 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
msgLen := int(binary.BigEndian.Uint16(data))
|
|
||||||
if len(data) < 2+msgLen {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
msg := make([]byte, msgLen)
|
|
||||||
copy(msg, data[2:2+msgLen])
|
|
||||||
c.reqBuf.Next(2 + msgLen)
|
|
||||||
|
|
||||||
resp, err := c.roundTrip(msg)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
var lenBuf [2]byte
|
|
||||||
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp)))
|
|
||||||
c.respBuf.Write(lenBuf[:])
|
|
||||||
c.respBuf.Write(resp)
|
|
||||||
}
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) Read(b []byte) (int, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.respBuf.Len() == 0 {
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
return c.respBuf.Read(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) roundTrip(query []byte) ([]byte, error) {
|
|
||||||
ctx := c.ctx
|
|
||||||
if !c.deadline.IsZero() {
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
ctx, cancel = context.WithDeadline(ctx, c.deadline)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", dohMimeType)
|
|
||||||
req.Header.Set("Accept", dohMimeType)
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(body) > dohMaxResponseSize {
|
|
||||||
return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize)
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) Close() error { return nil }
|
|
||||||
|
|
||||||
func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} }
|
|
||||||
func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} }
|
|
||||||
|
|
||||||
func (c *dohConn) SetDeadline(t time.Time) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
c.deadline = t
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
|
|
||||||
func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) }
|
|
||||||
|
|
||||||
type dohAddr struct {
|
|
||||||
network string
|
|
||||||
addr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *dohAddr) Network() string { return a.network }
|
|
||||||
func (a *dohAddr) String() string { return a.addr }
|
|
||||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
|
|
||||||
muxer := http.NewServeMux()
|
muxer := http.NewServeMux()
|
||||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||||
|
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||||
|
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||||
|
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||||
|
// frames and close the connection on invalid bytes.
|
||||||
|
c.PayloadType = websocket.BinaryFrame
|
||||||
notifyCh := make(chan struct{})
|
notifyCh := make(chan struct{})
|
||||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||||
close(notifyCh)
|
close(notifyCh)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "LoliaFRP-CLI 0.69.1"
|
var version = "0.70.1"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ import (
|
|||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
"github.com/fatedier/golib/pool"
|
"github.com/fatedier/golib/pool"
|
||||||
"golang.org/x/net/http2"
|
|
||||||
"golang.org/x/net/http2/h2c"
|
|
||||||
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
@@ -139,7 +137,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
|
|||||||
_, _ = rw.Write(getNotFoundPageContent())
|
_, _ = rw.Write(getNotFoundPageContent())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rp.proxy = h2c.NewHandler(proxy, &http2.Server{})
|
rp.proxy = proxy
|
||||||
return rp
|
return rp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,94 @@
|
|||||||
package vhost
|
package vhost
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestHTTPServerProtocols(t *testing.T) {
|
||||||
|
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
||||||
|
protocols := new(http.Protocols)
|
||||||
|
protocols.SetHTTP1(true)
|
||||||
|
protocols.SetUnencryptedHTTP2(true)
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: rp,
|
||||||
|
ReadHeaderTimeout: time.Second,
|
||||||
|
Protocols: protocols,
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
serveErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serveErr <- server.Serve(listener)
|
||||||
|
}()
|
||||||
|
defer func() {
|
||||||
|
require.NoError(t, server.Close())
|
||||||
|
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.True(t, server.Protocols.HTTP1())
|
||||||
|
require.True(t, server.Protocols.UnencryptedHTTP2())
|
||||||
|
|
||||||
|
t.Run("HTTP/1.1", func(t *testing.T) {
|
||||||
|
transport := &http.Transport{Protocols: httpProtocols(true, false)}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
response, err := client.Get("http://" + listener.Addr().String() + "/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, "HTTP/1.1", response.Proto)
|
||||||
|
require.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTTP/2 prior knowledge", func(t *testing.T) {
|
||||||
|
transport := &http.Transport{Protocols: httpProtocols(false, true)}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
response, err := client.Get("http://" + listener.Addr().String() + "/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, "HTTP/2.0", response.Proto)
|
||||||
|
require.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTTP/1.1 Upgrade h2c", func(t *testing.T) {
|
||||||
|
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn,
|
||||||
|
"GET / HTTP/1.1\r\nHost: %s\r\n"+
|
||||||
|
"Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\n"+
|
||||||
|
"HTTP2-Settings: AAMAAABkAAQCAAAAAAIAAAAA\r\n\r\n",
|
||||||
|
listener.Addr())
|
||||||
|
require.NoError(t, err)
|
||||||
|
response, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
require.NotEqual(t, http.StatusSwitchingProtocols, response.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpProtocols(http1, unencryptedHTTP2 bool) *http.Protocols {
|
||||||
|
protocols := new(http.Protocols)
|
||||||
|
protocols.SetHTTP1(http1)
|
||||||
|
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
|
||||||
|
return protocols
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckRouteAuthByRequest(t *testing.T) {
|
func TestCheckRouteAuthByRequest(t *testing.T) {
|
||||||
rc := &RouteConfig{
|
rc := &RouteConfig{
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
|
|||||||
+15
-62
@@ -28,70 +28,23 @@ var NotFoundPagePath = ""
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
NotFound = `<!DOCTYPE html>
|
NotFound = `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<title>Not Found</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<style>
|
||||||
<title>404 - 未绑定域名</title>
|
body {
|
||||||
<style>
|
width: 35em;
|
||||||
body {
|
margin: 0 auto;
|
||||||
font-family: -apple-system, sans-serif;
|
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
</style>
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
background: #fff;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
line-height: 1.8;
|
|
||||||
color: #666;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
text-align: left;
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #0066cc;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.footer {
|
|
||||||
margin-top: 40px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<h1>The page you requested was not found.</h1>
|
||||||
<h1>域名未绑定</h1>
|
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||||
<p>这个域名还没有绑定到任何隧道哦 (;д;)</p>
|
Please try again later.</p>
|
||||||
<p><strong>可能是这些原因:</strong></p>
|
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
|
||||||
<ul>
|
<p><em>Faithfully yours, frp.</em></p>
|
||||||
<li>域名配置不对,或者没有正确解析</li>
|
|
||||||
<li>隧道可能还没启动,或者已经停止</li>
|
|
||||||
<li>自定义域名忘记在服务端配置了</li>
|
|
||||||
</ul>
|
|
||||||
<div class="footer">由 <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
@@ -116,7 +69,7 @@ func getNotFoundPageContent() []byte {
|
|||||||
|
|
||||||
func NotFoundResponse() *http.Response {
|
func NotFoundResponse() *http.Response {
|
||||||
header := make(http.Header)
|
header := make(http.Header)
|
||||||
header.Set("server", version.Full())
|
header.Set("server", "frp/"+version.Full())
|
||||||
header.Set("Content-Type", "text/html")
|
header.Set("Content-Type", "text/html")
|
||||||
|
|
||||||
content := getNotFoundPageContent()
|
content := getNotFoundPageContent()
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ func (l *Logger) Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Tracef(format string, v ...any) {
|
func (l *Logger) Tracef(format string, v ...any) {
|
||||||
log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...)
|
log.Logger.Tracef(l.prefixString+format, v...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,12 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.Handle("/metrics", promhttp.Handler())
|
subRouter.Handle("/metrics", promhttp.Handler())
|
||||||
}
|
}
|
||||||
|
|
||||||
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
|
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
|
||||||
|
|
||||||
// apis
|
// apis
|
||||||
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
|
|
||||||
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
||||||
@@ -50,10 +49,15 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||||
|
|
||||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
||||||
|
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
||||||
|
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
||||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||||
|
v2EncodedPathRouter.UseEncodedPath()
|
||||||
|
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||||
|
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||||
|
|
||||||
// view
|
// view
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
|
|||||||
+451
-130
@@ -17,6 +17,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -40,103 +41,314 @@ import (
|
|||||||
"github.com/fatedier/frp/server/registry"
|
"github.com/fatedier/frp/server/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ControlID uint64
|
||||||
|
|
||||||
|
var nextControlID atomic.Uint64
|
||||||
|
|
||||||
|
type controlEntry struct {
|
||||||
|
ctl *Control
|
||||||
|
id ControlID
|
||||||
|
// runMu serializes lifecycle and routing decisions for one run ID.
|
||||||
|
// Replacements inherit it; removing the entry releases the manager's reference.
|
||||||
|
runMu *sync.Mutex
|
||||||
|
|
||||||
|
registryOnline bool
|
||||||
|
registryControlID ControlID
|
||||||
|
}
|
||||||
|
|
||||||
type ControlManager struct {
|
type ControlManager struct {
|
||||||
// controls indexed by run id
|
// controls indexed by run id
|
||||||
ctlsByRunID map[string]*Control
|
ctlsByRunID map[string]*controlEntry
|
||||||
|
registry *registry.ClientRegistry
|
||||||
|
closed bool
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewControlManager() *ControlManager {
|
func NewControlManager(clientRegistry *registry.ClientRegistry) *ControlManager {
|
||||||
return &ControlManager{
|
return &ControlManager{
|
||||||
ctlsByRunID: make(map[string]*Control),
|
ctlsByRunID: make(map[string]*controlEntry),
|
||||||
|
registry: clientRegistry,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ControlManager) Add(runID string, ctl *Control) (old *Control) {
|
// lockCurrentRun returns the current entry with its run gate held. It never
|
||||||
cm.mu.Lock()
|
// waits for the gate while holding cm.mu and revalidates the gate after waiting.
|
||||||
defer cm.mu.Unlock()
|
// The global order is runMu, cm.mu, ctl.lifecycleMu, then registry locks.
|
||||||
|
func (cm *ControlManager) lockCurrentRun(runID string, allowClosed bool) (*controlEntry, bool) {
|
||||||
var ok bool
|
cm.mu.RLock()
|
||||||
old, ok = cm.ctlsByRunID[runID]
|
entry, ok := cm.ctlsByRunID[runID]
|
||||||
if ok {
|
if cm.closed && !allowClosed {
|
||||||
old.Replaced(ctl)
|
ok = false
|
||||||
}
|
}
|
||||||
cm.ctlsByRunID[runID] = ctl
|
cm.mu.RUnlock()
|
||||||
return
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
runMu := entry.runMu
|
||||||
|
runMu.Lock()
|
||||||
|
cm.mu.RLock()
|
||||||
|
entry, ok = cm.ctlsByRunID[runID]
|
||||||
|
if (cm.closed && !allowClosed) || !ok || entry.runMu != runMu {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
runMu.Unlock()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return entry, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should make sure if it's the same control to prevent delete a new one
|
// Add makes ctl the pending current generation and records the predecessor
|
||||||
func (cm *ControlManager) Del(runID string, ctl *Control) {
|
// finalization barrier it must wait for before activation.
|
||||||
|
func (cm *ControlManager) Add(ctl *Control) error {
|
||||||
|
for {
|
||||||
|
// Never wait for a run gate while holding cm.mu.
|
||||||
|
cm.mu.RLock()
|
||||||
|
old := cm.ctlsByRunID[ctl.runID]
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
if old != nil {
|
||||||
|
old.runMu.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.mu.Lock()
|
||||||
|
if cm.closed {
|
||||||
|
cm.mu.Unlock()
|
||||||
|
if old != nil {
|
||||||
|
old.runMu.Unlock()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("control manager is closed")
|
||||||
|
}
|
||||||
|
if cm.ctlsByRunID[ctl.runID] != old {
|
||||||
|
cm.mu.Unlock()
|
||||||
|
if old != nil {
|
||||||
|
old.runMu.Unlock()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := ControlID(nextControlID.Add(1))
|
||||||
|
if err := ctl.admit(cm, id); err != nil {
|
||||||
|
cm.mu.Unlock()
|
||||||
|
if old != nil {
|
||||||
|
old.runMu.Unlock()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runMu := &sync.Mutex{}
|
||||||
|
if old != nil {
|
||||||
|
runMu = old.runMu
|
||||||
|
}
|
||||||
|
entry := &controlEntry{ctl: ctl, id: id, runMu: runMu}
|
||||||
|
var (
|
||||||
|
oldCtl *Control
|
||||||
|
barrier <-chan struct{}
|
||||||
|
)
|
||||||
|
if old != nil {
|
||||||
|
oldCtl = old.ctl
|
||||||
|
barrier = oldCtl.markReplaced()
|
||||||
|
ctl.setHandoffBarrier(barrier)
|
||||||
|
entry.registryOnline = old.registryOnline
|
||||||
|
entry.registryControlID = old.registryControlID
|
||||||
|
}
|
||||||
|
cm.ctlsByRunID[ctl.runID] = entry
|
||||||
|
cm.mu.Unlock()
|
||||||
|
if old != nil {
|
||||||
|
old.runMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldCtl != nil {
|
||||||
|
oldCtl.Replaced(ctl)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate registers ctl as online only if it is still the pending current
|
||||||
|
// generation.
|
||||||
|
func (cm *ControlManager) Activate(ctl *Control) (bool, error) {
|
||||||
|
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer entry.runMu.Unlock()
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
defer cm.mu.Unlock()
|
||||||
if c, ok := cm.ctlsByRunID[runID]; ok && c == ctl {
|
|
||||||
delete(cm.ctlsByRunID, runID)
|
if cm.closed || cm.ctlsByRunID[ctl.runID] != entry || entry.ctl != ctl || entry.id != ctl.controlID {
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
if ctl.state != controlStatePending {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if ctl.activated {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loginMsg := ctl.sessionCtx.LoginMsg
|
||||||
|
remoteAddr := ctl.sessionCtx.Conn.RemoteAddr().String()
|
||||||
|
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
|
remoteAddr = host
|
||||||
|
}
|
||||||
|
_, conflict := cm.registry.RegisterWithControlID(
|
||||||
|
loginMsg.User,
|
||||||
|
loginMsg.ClientID,
|
||||||
|
ctl.runID,
|
||||||
|
loginMsg.Hostname,
|
||||||
|
loginMsg.Version,
|
||||||
|
remoteAddr,
|
||||||
|
ctl.sessionCtx.WireProtocol,
|
||||||
|
uint64(entry.id),
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
return true, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.registryOnline = true
|
||||||
|
entry.registryControlID = entry.id
|
||||||
|
ctl.activated = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeLogin reserves ctl's current ownership with its run gate while the
|
||||||
|
// bounded successful LoginResp write runs, then transitions it to running.
|
||||||
|
// The callback must only perform that bounded write; it must not call back into
|
||||||
|
// the control manager or the same control lifecycle.
|
||||||
|
func (cm *ControlManager) completeLogin(ctl *Control, writeSuccess func() error) (bool, error) {
|
||||||
|
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer entry.runMu.Unlock()
|
||||||
|
if entry.ctl != ctl || entry.id != ctl.controlID {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
if ctl.state != controlStatePending || !ctl.activated {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := writeSuccess(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !ctl.startLocked() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes and offlines ctl only if it is still the current generation.
|
||||||
|
func (cm *ControlManager) Remove(ctl *Control) bool {
|
||||||
|
entry, ok := cm.lockCurrentRun(ctl.runID, true)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer entry.runMu.Unlock()
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.ctlsByRunID[ctl.runID] != entry || entry.ctl != ctl || entry.id != ctl.controlID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(cm.ctlsByRunID, ctl.runID)
|
||||||
|
if entry.registryOnline {
|
||||||
|
cm.registry.MarkOfflineByRunIDAndControlID(ctl.runID, uint64(entry.registryControlID))
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
|
func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
|
||||||
cm.mu.RLock()
|
entry, ok := cm.lockCurrentRun(runID, false)
|
||||||
defer cm.mu.RUnlock()
|
if !ok {
|
||||||
ctl, ok = cm.ctlsByRunID[runID]
|
return nil, false
|
||||||
return
|
}
|
||||||
|
defer entry.runMu.Unlock()
|
||||||
|
ctl = entry.ctl
|
||||||
|
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
if ctl.state != controlStateRunning {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ctl, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// admitVisitorByRunID commits a visitor admission against the current running
|
||||||
|
// control while its run and lifecycle ownership are held. The callback must
|
||||||
|
// only perform the in-memory, buffered visitor admission.
|
||||||
|
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user string) error) (bool, error) {
|
||||||
|
entry, ok := cm.lockCurrentRun(runID, false)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer entry.runMu.Unlock()
|
||||||
|
ctl := entry.ctl
|
||||||
|
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
if ctl.state != controlStateRunning {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, admit(ctl.sessionCtx.LoginMsg.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWorkConn transfers conn to ctl only if ctl is still the current
|
||||||
|
// running generation. On error, ownership remains with the caller.
|
||||||
|
func (cm *ControlManager) RegisterWorkConn(ctl *Control, conn *proxy.WorkConn) error {
|
||||||
|
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
||||||
|
if !ok {
|
||||||
|
cm.mu.RLock()
|
||||||
|
closed := cm.closed
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
if closed {
|
||||||
|
return fmt.Errorf("control manager is closed")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("client control for run id [%s] is no longer current", ctl.runID)
|
||||||
|
}
|
||||||
|
defer entry.runMu.Unlock()
|
||||||
|
if entry.ctl != ctl || entry.id != ctl.controlID {
|
||||||
|
return fmt.Errorf("client control for run id [%s] is no longer current", ctl.runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
if ctl.state != controlStateRunning {
|
||||||
|
return fmt.Errorf("client control for run id [%s] is not running", ctl.runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ctl.workConnCh <- conn:
|
||||||
|
ctl.xl.Debugf("new work connection registered")
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
ctl.xl.Debugf("work connection pool is full, discarding")
|
||||||
|
return fmt.Errorf("work connection pool is full, discarding")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ControlManager) Close() error {
|
func (cm *ControlManager) Close() error {
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
cm.closed = true
|
||||||
for _, ctl := range cm.ctlsByRunID {
|
ctls := make([]*Control, 0, len(cm.ctlsByRunID))
|
||||||
ctl.Close()
|
for _, entry := range cm.ctlsByRunID {
|
||||||
|
ctls = append(ctls, entry.ctl)
|
||||||
|
}
|
||||||
|
cm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, ctl := range ctls {
|
||||||
|
cm.Remove(ctl)
|
||||||
|
_ = ctl.Close()
|
||||||
}
|
}
|
||||||
cm.ctlsByRunID = make(map[string]*Control)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseAllProxyByName Finds the tunnel name and closes all tunnels on the same connection.
|
|
||||||
func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
|
|
||||||
cm.mu.RLock()
|
|
||||||
var target *Control
|
|
||||||
for _, ctl := range cm.ctlsByRunID {
|
|
||||||
ctl.mu.RLock()
|
|
||||||
_, ok := ctl.proxies[proxyName]
|
|
||||||
ctl.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
target = ctl
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
if target == nil {
|
|
||||||
return fmt.Errorf("no proxy found with name [%s]", proxyName)
|
|
||||||
}
|
|
||||||
return target.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// KickByProxyName finds the Control that manages the given proxy (tunnel) name and closes
|
|
||||||
// Bug: The client does not display the kickout message.
|
|
||||||
func (cm *ControlManager) KickByProxyName(proxyName string) error {
|
|
||||||
cm.mu.RLock()
|
|
||||||
var target *Control
|
|
||||||
for _, ctl := range cm.ctlsByRunID {
|
|
||||||
ctl.mu.RLock()
|
|
||||||
_, ok := ctl.proxies[proxyName]
|
|
||||||
ctl.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
target = ctl
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
|
|
||||||
if target == nil {
|
|
||||||
return fmt.Errorf("no proxy found with name [%s]", proxyName)
|
|
||||||
}
|
|
||||||
|
|
||||||
xl := target.xl
|
|
||||||
xl.Infof("kick client with proxy [%s] by server administrator request", proxyName)
|
|
||||||
return target.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionContext encapsulates the input parameters for creating a new Control.
|
// SessionContext encapsulates the input parameters for creating a new Control.
|
||||||
type SessionContext struct {
|
type SessionContext struct {
|
||||||
// all resource managers and controllers
|
// all resource managers and controllers
|
||||||
@@ -155,12 +367,20 @@ type SessionContext struct {
|
|||||||
LoginMsg *msg.Login
|
LoginMsg *msg.Login
|
||||||
// server configuration
|
// server configuration
|
||||||
ServerCfg *v1.ServerConfig
|
ServerCfg *v1.ServerConfig
|
||||||
// client registry
|
|
||||||
ClientRegistry *registry.ClientRegistry
|
|
||||||
// negotiated wire protocol for this client session
|
// negotiated wire protocol for this client session
|
||||||
WireProtocol string
|
WireProtocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type controlState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
controlStateCreated controlState = iota
|
||||||
|
controlStatePending
|
||||||
|
controlStateRunning
|
||||||
|
controlStateClosing
|
||||||
|
controlStateClosed
|
||||||
|
)
|
||||||
|
|
||||||
type Control struct {
|
type Control struct {
|
||||||
// session context
|
// session context
|
||||||
sessionCtx *SessionContext
|
sessionCtx *SessionContext
|
||||||
@@ -187,30 +407,42 @@ type Control struct {
|
|||||||
// last time got the Ping message
|
// last time got the Ping message
|
||||||
lastPing atomic.Value
|
lastPing atomic.Value
|
||||||
|
|
||||||
// A new run id will be generated when a new client login.
|
// runID never changes during the lifetime of a control. controlID is assigned
|
||||||
// If run id got from login message has same run id, it means it's the same client, so we can
|
// once by ControlManager and distinguishes same-runID generations.
|
||||||
// replace old controller instantly.
|
runID string
|
||||||
runID string
|
controlID ControlID
|
||||||
|
manager *ControlManager
|
||||||
|
|
||||||
|
lifecycleMu sync.Mutex
|
||||||
|
state controlState
|
||||||
|
activated bool
|
||||||
|
handoffBarrier <-chan struct{}
|
||||||
|
|
||||||
|
interruptOnce sync.Once
|
||||||
|
interruptErr error
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
serverMetrics metrics.ServerMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
|
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
|
||||||
poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
|
poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
|
||||||
ctl := &Control{
|
ctl := &Control{
|
||||||
sessionCtx: sessionCtx,
|
sessionCtx: sessionCtx,
|
||||||
workConnCh: make(chan *proxy.WorkConn, poolCount+10),
|
workConnCh: make(chan *proxy.WorkConn, poolCount+10),
|
||||||
proxies: make(map[string]proxy.Proxy),
|
proxies: make(map[string]proxy.Proxy),
|
||||||
poolCount: poolCount,
|
poolCount: poolCount,
|
||||||
portsUsedNum: 0,
|
portsUsedNum: 0,
|
||||||
runID: sessionCtx.LoginMsg.RunID,
|
runID: sessionCtx.LoginMsg.RunID,
|
||||||
xl: xlog.FromContextSafe(ctx),
|
state: controlStateCreated,
|
||||||
ctx: ctx,
|
xl: xlog.FromContextSafe(ctx),
|
||||||
doneCh: make(chan struct{}),
|
ctx: ctx,
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
serverMetrics: metrics.Server,
|
||||||
}
|
}
|
||||||
ctl.lastPing.Store(time.Now())
|
ctl.lastPing.Store(time.Now())
|
||||||
|
|
||||||
@@ -220,48 +452,121 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
|||||||
return ctl, nil
|
return ctl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the control session workers after login succeeds.
|
func (ctl *Control) RunID() string {
|
||||||
func (ctl *Control) Start() {
|
return ctl.runID
|
||||||
go func() {
|
|
||||||
for i := 0; i < ctl.poolCount; i++ {
|
|
||||||
// ignore error here, that means that this control is closed
|
|
||||||
_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
go ctl.worker()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) Close() error {
|
func (ctl *Control) ID() ControlID {
|
||||||
ctl.sessionCtx.Conn.Close()
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
return ctl.controlID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *Control) admit(manager *ControlManager, id ControlID) error {
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
if ctl.state != controlStateCreated {
|
||||||
|
return fmt.Errorf("control [%s] is not in created state", ctl.runID)
|
||||||
|
}
|
||||||
|
ctl.manager = manager
|
||||||
|
ctl.controlID = id
|
||||||
|
ctl.state = controlStatePending
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) Replaced(newCtl *Control) {
|
func (ctl *Control) setHandoffBarrier(barrier <-chan struct{}) {
|
||||||
xl := ctl.xl
|
ctl.lifecycleMu.Lock()
|
||||||
xl.Infof("replaced by client [%s]", newCtl.runID)
|
ctl.handoffBarrier = barrier
|
||||||
ctl.runID = ""
|
ctl.lifecycleMu.Unlock()
|
||||||
ctl.sessionCtx.Conn.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) RegisterWorkConn(conn *proxy.WorkConn) error {
|
func (ctl *Control) WaitForHandoff() {
|
||||||
xl := ctl.xl
|
ctl.lifecycleMu.Lock()
|
||||||
defer func() {
|
barrier := ctl.handoffBarrier
|
||||||
if err := recover(); err != nil {
|
ctl.lifecycleMu.Unlock()
|
||||||
xl.Errorf("panic error: %v", err)
|
if barrier != nil {
|
||||||
xl.Errorf(string(debug.Stack()))
|
<-barrier
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case ctl.workConnCh <- conn:
|
|
||||||
xl.Debugf("new work connection registered")
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
xl.Debugf("work connection pool is full, discarding")
|
|
||||||
return fmt.Errorf("work connection pool is full, discarding")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start starts the control session workers after login succeeds.
|
||||||
|
func (ctl *Control) Start() bool {
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
return ctl.startLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *Control) startLocked() bool {
|
||||||
|
if ctl.state != controlStatePending || !ctl.activated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctl.state = controlStateRunning
|
||||||
|
go ctl.worker()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *Control) Close() error {
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
switch ctl.state {
|
||||||
|
case controlStateCreated, controlStatePending:
|
||||||
|
ctl.state = controlStateClosing
|
||||||
|
ctl.finishLocked()
|
||||||
|
case controlStateRunning:
|
||||||
|
ctl.state = controlStateClosing
|
||||||
|
}
|
||||||
|
ctl.lifecycleMu.Unlock()
|
||||||
|
return ctl.interruptReadAndClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *Control) Replaced(newCtl *Control) {
|
||||||
|
ctl.markReplaced()
|
||||||
|
ctl.xl.Infof("replaced by client [%s] (control ID %d)", newCtl.runID, newCtl.ID())
|
||||||
|
_ = ctl.interruptReadAndClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// markReplaced returns the transitive predecessor barrier. A pending control
|
||||||
|
// has no worker, so it finishes immediately and passes its inherited barrier
|
||||||
|
// to the replacement. A running control is finished only by its worker.
|
||||||
|
func (ctl *Control) markReplaced() <-chan struct{} {
|
||||||
|
ctl.lifecycleMu.Lock()
|
||||||
|
defer ctl.lifecycleMu.Unlock()
|
||||||
|
|
||||||
|
switch ctl.state {
|
||||||
|
case controlStateCreated:
|
||||||
|
ctl.state = controlStateClosing
|
||||||
|
ctl.finishLocked()
|
||||||
|
return nil
|
||||||
|
case controlStatePending:
|
||||||
|
barrier := ctl.handoffBarrier
|
||||||
|
ctl.state = controlStateClosing
|
||||||
|
ctl.finishLocked()
|
||||||
|
return barrier
|
||||||
|
case controlStateRunning:
|
||||||
|
ctl.state = controlStateClosing
|
||||||
|
return ctl.doneCh
|
||||||
|
case controlStateClosing, controlStateClosed:
|
||||||
|
return ctl.doneCh
|
||||||
|
default:
|
||||||
|
return ctl.doneCh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *Control) interruptReadAndClose() error {
|
||||||
|
ctl.interruptOnce.Do(func() {
|
||||||
|
_ = ctl.sessionCtx.Conn.SetReadDeadline(time.Now())
|
||||||
|
ctl.interruptErr = ctl.sessionCtx.Conn.Close()
|
||||||
|
})
|
||||||
|
return ctl.interruptErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *Control) finishLocked() {
|
||||||
|
if ctl.state == controlStateClosed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctl.state = controlStateClosed
|
||||||
|
close(ctl.doneCh)
|
||||||
|
}
|
||||||
|
|
||||||
// When frps get one user connection, we get one work connection from the pool and return it.
|
// When frps get one user connection, we get one work connection from the pool and return it.
|
||||||
// If no workConn available in the pool, send message to frpc to get one or more
|
// If no workConn available in the pool, send message to frpc to get one or more
|
||||||
// and wait until it is available.
|
// and wait until it is available.
|
||||||
@@ -316,10 +621,10 @@ func (ctl *Control) heartbeatWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
xl := ctl.xl
|
xl := ctl.xl
|
||||||
go wait.Until(func() {
|
wait.Until(func() {
|
||||||
if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
|
if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
|
||||||
xl.Warnf("heartbeat timeout")
|
xl.Warnf("heartbeat timeout")
|
||||||
ctl.sessionCtx.Conn.Close()
|
_ = ctl.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, time.Second, ctl.doneCh)
|
}, time.Second, ctl.doneCh)
|
||||||
@@ -334,14 +639,14 @@ func (ctl *Control) loginUserInfo() plugin.UserInfo {
|
|||||||
return plugin.UserInfo{
|
return plugin.UserInfo{
|
||||||
User: ctl.sessionCtx.LoginMsg.User,
|
User: ctl.sessionCtx.LoginMsg.User,
|
||||||
Metas: ctl.sessionCtx.LoginMsg.Metas,
|
Metas: ctl.sessionCtx.LoginMsg.Metas,
|
||||||
RunID: ctl.sessionCtx.LoginMsg.RunID,
|
RunID: ctl.runID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
ctl.sessionCtx.PxyManager.Del(pxy.GetName())
|
ctl.sessionCtx.PxyManager.Del(pxy.GetName())
|
||||||
metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
ctl.serverMetrics.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||||
|
|
||||||
notifyContent := &plugin.CloseProxyContent{
|
notifyContent := &plugin.CloseProxyContent{
|
||||||
User: ctl.loginUserInfo(),
|
User: ctl.loginUserInfo(),
|
||||||
@@ -356,12 +661,24 @@ func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
|||||||
|
|
||||||
func (ctl *Control) worker() {
|
func (ctl *Control) worker() {
|
||||||
xl := ctl.xl
|
xl := ctl.xl
|
||||||
|
ctl.serverMetrics.NewClient()
|
||||||
|
|
||||||
go ctl.heartbeatWorker()
|
go ctl.heartbeatWorker()
|
||||||
go ctl.msgDispatcher.Run()
|
go ctl.msgDispatcher.Run()
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < ctl.poolCount; i++ {
|
||||||
|
// Ignore the error: it means this control is already closing.
|
||||||
|
_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
<-ctl.msgDispatcher.Done()
|
<-ctl.msgDispatcher.Done()
|
||||||
ctl.sessionCtx.Conn.Close()
|
ctl.lifecycleMu.Lock()
|
||||||
|
if ctl.state == controlStateRunning {
|
||||||
|
ctl.state = controlStateClosing
|
||||||
|
}
|
||||||
|
ctl.lifecycleMu.Unlock()
|
||||||
|
_ = ctl.interruptReadAndClose()
|
||||||
|
|
||||||
ctl.mu.Lock()
|
ctl.mu.Lock()
|
||||||
close(ctl.workConnCh)
|
close(ctl.workConnCh)
|
||||||
@@ -376,10 +693,14 @@ func (ctl *Control) worker() {
|
|||||||
ctl.closeProxy(pxy)
|
ctl.closeProxy(pxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics.Server.CloseClient()
|
ctl.serverMetrics.CloseClient()
|
||||||
ctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID)
|
if ctl.manager != nil {
|
||||||
|
ctl.manager.Remove(ctl)
|
||||||
|
}
|
||||||
xl.Infof("client exit success")
|
xl.Infof("client exit success")
|
||||||
close(ctl.doneCh)
|
ctl.lifecycleMu.Lock()
|
||||||
|
ctl.finishLocked()
|
||||||
|
ctl.lifecycleMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) registerMsgHandlers() {
|
func (ctl *Control) registerMsgHandlers() {
|
||||||
@@ -419,9 +740,9 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
|
|||||||
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
||||||
clientID := ctl.sessionCtx.LoginMsg.ClientID
|
clientID := ctl.sessionCtx.LoginMsg.ClientID
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = ctl.sessionCtx.LoginMsg.RunID
|
clientID = ctl.runID
|
||||||
}
|
}
|
||||||
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
|
ctl.serverMetrics.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
|
||||||
}
|
}
|
||||||
_ = ctl.msgDispatcher.Send(resp)
|
_ = ctl.msgDispatcher.Send(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
// 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 server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/auth"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||||
|
"github.com/fatedier/frp/server/controller"
|
||||||
|
"github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControlPendingReplacementFinishesWithoutStarting(t *testing.T) {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
oldCtl, oldConn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, oldCtl)
|
||||||
|
|
||||||
|
err := manager.Add(newCtl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
waitForControlDone(t, oldCtl)
|
||||||
|
require.False(t, oldCtl.Start())
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, oldConn.eventsSnapshot())
|
||||||
|
require.Equal(t, int64(0), metrics.newClients())
|
||||||
|
require.Equal(t, int64(0), metrics.closedClients())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlRunningReplacementFinishesInWorker(t *testing.T) {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
oldCtl, oldConn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, oldCtl)
|
||||||
|
require.True(t, oldCtl.Start())
|
||||||
|
waitForSignal(t, oldConn.readStarted, "control reader to start")
|
||||||
|
|
||||||
|
err := manager.Add(newCtl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
waitForControlDone(t, oldCtl)
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, oldConn.eventsSnapshot())
|
||||||
|
require.Equal(t, int64(1), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
|
||||||
|
_, ok := manager.GetByID("same-run")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Same(t, newCtl, currentControlForTest(manager, "same-run"))
|
||||||
|
info, ok := clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, info.Online)
|
||||||
|
require.Equal(t, uint64(oldCtl.ID()), info.ControlID)
|
||||||
|
|
||||||
|
active, err := manager.Activate(newCtl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, active)
|
||||||
|
_, ok = manager.GetByID("same-run")
|
||||||
|
require.False(t, ok)
|
||||||
|
info, ok = clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, uint64(newCtl.ID()), info.ControlID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlClosePendingAndRunning(t *testing.T) {
|
||||||
|
t.Run("pending", func(t *testing.T) {
|
||||||
|
manager := NewControlManager(registry.NewClientRegistry())
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, conn := newLifecycleTestControl(t, "pending", "pending", metrics)
|
||||||
|
err := manager.Add(ctl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||||
|
require.Equal(t, int64(0), metrics.newClients())
|
||||||
|
require.Equal(t, int64(0), metrics.closedClients())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("running", func(t *testing.T) {
|
||||||
|
manager := NewControlManager(registry.NewClientRegistry())
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, conn := newLifecycleTestControl(t, "running", "running", metrics)
|
||||||
|
mustAddAndActivate(t, manager, ctl)
|
||||||
|
require.True(t, ctl.Start())
|
||||||
|
waitForSignal(t, conn.readStarted, "control reader to start")
|
||||||
|
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||||
|
require.Equal(t, int64(1), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlCloseAndReplacedAreIdempotent(t *testing.T) {
|
||||||
|
manager := NewControlManager(registry.NewClientRegistry())
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, conn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
err := manager.Add(ctl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = manager.Add(replacement)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
ctl.Replaced(replacement)
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||||
|
require.Equal(t, int64(0), metrics.newClients())
|
||||||
|
require.Equal(t, int64(0), metrics.closedClients())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlHeartbeatTimeoutInterruptsRead(t *testing.T) {
|
||||||
|
manager := NewControlManager(registry.NewClientRegistry())
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, conn := newLifecycleTestControl(t, "heartbeat", "heartbeat", metrics)
|
||||||
|
ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout = 1
|
||||||
|
ctl.lastPing.Store(time.Now().Add(-2 * time.Second))
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, ctl)
|
||||||
|
require.True(t, ctl.Start())
|
||||||
|
waitForSignal(t, conn.readStarted, "control reader to start")
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
||||||
|
require.Equal(t, int64(1), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlStartReplacementRacePairsMetrics(t *testing.T) {
|
||||||
|
for range 100 {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, ctl)
|
||||||
|
|
||||||
|
startGate := make(chan struct{})
|
||||||
|
startedCh := make(chan bool, 1)
|
||||||
|
addErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
<-startGate
|
||||||
|
startedCh <- ctl.Start()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
<-startGate
|
||||||
|
addErr := manager.Add(replacement)
|
||||||
|
addErrCh <- addErr
|
||||||
|
}()
|
||||||
|
close(startGate)
|
||||||
|
|
||||||
|
started := <-startedCh
|
||||||
|
require.NoError(t, <-addErrCh)
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
if started {
|
||||||
|
require.Equal(t, int64(1), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
} else {
|
||||||
|
require.Equal(t, int64(0), metrics.newClients())
|
||||||
|
require.Equal(t, int64(0), metrics.closedClients())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlManagerRejectsStaleActivateAndRemove(t *testing.T) {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
oldCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, oldCtl)
|
||||||
|
err := manager.Add(newCtl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, uint64(newCtl.ID()), uint64(oldCtl.ID()))
|
||||||
|
|
||||||
|
active, err := manager.Activate(oldCtl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, active)
|
||||||
|
require.False(t, manager.Remove(oldCtl))
|
||||||
|
|
||||||
|
_, ok := manager.GetByID("same-run")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Same(t, newCtl, currentControlForTest(manager, "same-run"))
|
||||||
|
info, ok := clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, info.Online)
|
||||||
|
require.Equal(t, uint64(oldCtl.ID()), info.ControlID)
|
||||||
|
|
||||||
|
active, err = manager.Activate(newCtl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, active)
|
||||||
|
info, ok = clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, info.Online)
|
||||||
|
require.Equal(t, uint64(newCtl.ID()), info.ControlID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlManagerPreservesClientIDConflict(t *testing.T) {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
first, _ := newLifecycleTestControl(t, "run-one", "shared-client", metrics)
|
||||||
|
conflicting, _ := newLifecycleTestControl(t, "run-two", "shared-client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, first)
|
||||||
|
err := manager.Add(conflicting)
|
||||||
|
require.NoError(t, err)
|
||||||
|
active, err := manager.Activate(conflicting)
|
||||||
|
require.True(t, active)
|
||||||
|
require.ErrorContains(t, err, "already online")
|
||||||
|
|
||||||
|
require.True(t, manager.Remove(conflicting))
|
||||||
|
info, ok := clientRegistry.GetByKey("shared-client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, info.Online)
|
||||||
|
require.Equal(t, "run-one", info.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlManagerFailedLoginWriteReleasesRunWithoutStarting(t *testing.T) {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, ctl)
|
||||||
|
|
||||||
|
writeErr := errors.New("write failed")
|
||||||
|
committed, err := manager.completeLogin(ctl, func() error { return writeErr })
|
||||||
|
require.ErrorIs(t, err, writeErr)
|
||||||
|
require.False(t, committed)
|
||||||
|
|
||||||
|
err = manager.Add(replacement)
|
||||||
|
require.NoError(t, err)
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
require.Same(t, replacement, currentControlForTest(manager, "same-run"))
|
||||||
|
require.Equal(t, int64(0), metrics.newClients())
|
||||||
|
require.Equal(t, int64(0), metrics.closedClients())
|
||||||
|
require.True(t, manager.Remove(replacement))
|
||||||
|
info, ok := clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.False(t, info.Online)
|
||||||
|
require.Empty(t, info.RunID)
|
||||||
|
require.Zero(t, info.ControlID)
|
||||||
|
require.False(t, info.DisconnectedAt.IsZero())
|
||||||
|
require.NoError(t, replacement.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlManagerCloseWaitsForInFlightLoginRun(t *testing.T) {
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
manager := NewControlManager(clientRegistry)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
||||||
|
|
||||||
|
mustAddAndActivate(t, manager, ctl)
|
||||||
|
|
||||||
|
writeEntered := make(chan struct{})
|
||||||
|
resumeWrite := make(chan struct{})
|
||||||
|
loginDone := make(chan struct {
|
||||||
|
committed bool
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
go func() {
|
||||||
|
committed, loginErr := manager.completeLogin(ctl, func() error {
|
||||||
|
close(writeEntered)
|
||||||
|
<-resumeWrite
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
loginDone <- struct {
|
||||||
|
committed bool
|
||||||
|
err error
|
||||||
|
}{committed: committed, err: loginErr}
|
||||||
|
}()
|
||||||
|
waitForSignal(t, writeEntered, "LoginResp write")
|
||||||
|
|
||||||
|
closeDone := make(chan error, 1)
|
||||||
|
go func() { closeDone <- manager.Close() }()
|
||||||
|
waitForManagerClosed(t, manager)
|
||||||
|
select {
|
||||||
|
case err := <-closeDone:
|
||||||
|
t.Fatalf("manager close completed during LoginResp write: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
close(resumeWrite)
|
||||||
|
result := <-loginDone
|
||||||
|
require.NoError(t, result.err)
|
||||||
|
require.True(t, result.committed)
|
||||||
|
require.NoError(t, <-closeDone)
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
require.Nil(t, currentControlForTest(manager, "same-run"))
|
||||||
|
require.Equal(t, int64(1), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
info, ok := clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.False(t, info.Online)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLifecycleTestControl(
|
||||||
|
t *testing.T,
|
||||||
|
runID string,
|
||||||
|
clientID string,
|
||||||
|
serverMetrics *countingServerMetrics,
|
||||||
|
) (*Control, *deadlineReadConn) {
|
||||||
|
t.Helper()
|
||||||
|
conn := newDeadlineReadConn()
|
||||||
|
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||||
|
ctl, err := NewControl(context.Background(), &SessionContext{
|
||||||
|
RC: &controller.ResourceController{},
|
||||||
|
PxyManager: proxy.NewManager(),
|
||||||
|
PluginManager: plugin.NewManager(),
|
||||||
|
AuthVerifier: auth.AlwaysPassVerifier,
|
||||||
|
Conn: msgConn,
|
||||||
|
LoginMsg: &msg.Login{
|
||||||
|
RunID: runID,
|
||||||
|
ClientID: clientID,
|
||||||
|
},
|
||||||
|
ServerCfg: &v1.ServerConfig{},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctl.serverMetrics = serverMetrics
|
||||||
|
t.Cleanup(func() { _ = ctl.Close() })
|
||||||
|
return ctl, conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustAddAndActivate(t *testing.T, manager *ControlManager, ctl *Control) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, manager.Add(ctl))
|
||||||
|
active, err := manager.Activate(ctl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForControlDone(t *testing.T, ctl *Control) {
|
||||||
|
t.Helper()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ctl.WaitClosed()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
waitForSignal(t, done, "control to finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentControlForTest(manager *ControlManager, runID string) *Control {
|
||||||
|
manager.mu.RLock()
|
||||||
|
defer manager.mu.RUnlock()
|
||||||
|
entry := manager.ctlsByRunID[runID]
|
||||||
|
if entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry.ctl
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentRunGateForTest(manager *ControlManager, runID string) *sync.Mutex {
|
||||||
|
manager.mu.RLock()
|
||||||
|
defer manager.mu.RUnlock()
|
||||||
|
entry := manager.ctlsByRunID[runID]
|
||||||
|
if entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry.runMu
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForManagerClosed(t *testing.T, manager *ControlManager) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
manager.mu.RLock()
|
||||||
|
closed := manager.closed
|
||||||
|
manager.mu.RUnlock()
|
||||||
|
if closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("timed out waiting for control manager to close")
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForSignal(t *testing.T, ch <-chan struct{}, description string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for %s", description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deadlineReadConn struct {
|
||||||
|
readStarted chan struct{}
|
||||||
|
unblockRead chan struct{}
|
||||||
|
|
||||||
|
readOnce sync.Once
|
||||||
|
unblockOnce sync.Once
|
||||||
|
deadlineOnce sync.Once
|
||||||
|
closeOnce sync.Once
|
||||||
|
|
||||||
|
eventsMu sync.Mutex
|
||||||
|
events []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeadlineReadConn() *deadlineReadConn {
|
||||||
|
return &deadlineReadConn{
|
||||||
|
readStarted: make(chan struct{}),
|
||||||
|
unblockRead: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *deadlineReadConn) Read([]byte) (int, error) {
|
||||||
|
c.readOnce.Do(func() { close(c.readStarted) })
|
||||||
|
<-c.unblockRead
|
||||||
|
return 0, os.ErrDeadlineExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*deadlineReadConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
|
||||||
|
func (c *deadlineReadConn) Close() error {
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
c.recordEvent("close")
|
||||||
|
c.unblockOnce.Do(func() { close(c.unblockRead) })
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*deadlineReadConn) LocalAddr() net.Addr { return lifecycleTestAddr("local") }
|
||||||
|
func (*deadlineReadConn) RemoteAddr() net.Addr { return lifecycleTestAddr("remote") }
|
||||||
|
|
||||||
|
func (c *deadlineReadConn) SetDeadline(deadline time.Time) error {
|
||||||
|
if err := c.SetReadDeadline(deadline); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.SetWriteDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *deadlineReadConn) SetReadDeadline(deadline time.Time) error {
|
||||||
|
if deadline.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.deadlineOnce.Do(func() {
|
||||||
|
c.recordEvent("deadline")
|
||||||
|
c.unblockOnce.Do(func() { close(c.unblockRead) })
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*deadlineReadConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
|
func (c *deadlineReadConn) recordEvent(event string) {
|
||||||
|
c.eventsMu.Lock()
|
||||||
|
c.events = append(c.events, event)
|
||||||
|
c.eventsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *deadlineReadConn) eventsSnapshot() []string {
|
||||||
|
c.eventsMu.Lock()
|
||||||
|
defer c.eventsMu.Unlock()
|
||||||
|
return append([]string(nil), c.events...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type lifecycleTestAddr string
|
||||||
|
|
||||||
|
func (a lifecycleTestAddr) Network() string { return string(a) }
|
||||||
|
func (a lifecycleTestAddr) String() string { return string(a) }
|
||||||
|
|
||||||
|
type countingServerMetrics struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
newCount int64
|
||||||
|
closeCount int64
|
||||||
|
closeEnter chan struct{}
|
||||||
|
closeResume chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCountingServerMetrics() *countingServerMetrics {
|
||||||
|
return &countingServerMetrics{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingServerMetrics) NewClient() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.newCount++
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingServerMetrics) CloseClient() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.closeCount++
|
||||||
|
closeEnter := m.closeEnter
|
||||||
|
closeResume := m.closeResume
|
||||||
|
m.mu.Unlock()
|
||||||
|
if closeEnter != nil {
|
||||||
|
m.closeOnce.Do(func() { close(closeEnter) })
|
||||||
|
<-closeResume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*countingServerMetrics) NewProxy(string, string, string, string) {}
|
||||||
|
func (*countingServerMetrics) CloseProxy(string, string) {}
|
||||||
|
func (*countingServerMetrics) OpenConnection(string, string) {}
|
||||||
|
func (*countingServerMetrics) CloseConnection(string, string) {}
|
||||||
|
func (*countingServerMetrics) AddTrafficIn(string, string, int64) {}
|
||||||
|
func (*countingServerMetrics) AddTrafficOut(string, string, int64) {}
|
||||||
|
|
||||||
|
func (m *countingServerMetrics) newClients() int64 {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.newCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingServerMetrics) closedClients() int64 {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.closeCount
|
||||||
|
}
|
||||||
@@ -38,35 +38,32 @@ type Controller struct {
|
|||||||
serverCfg *v1.ServerConfig
|
serverCfg *v1.ServerConfig
|
||||||
clientRegistry *registry.ClientRegistry
|
clientRegistry *registry.ClientRegistry
|
||||||
pxyManager ProxyManager
|
pxyManager ProxyManager
|
||||||
ctlManager ControlManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyManager interface {
|
type ProxyManager interface {
|
||||||
GetByName(name string) (proxy.Proxy, bool)
|
GetByName(name string) (proxy.Proxy, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ControlManager interface {
|
|
||||||
CloseAllProxyByName(proxyName string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewController(
|
func NewController(
|
||||||
serverCfg *v1.ServerConfig,
|
serverCfg *v1.ServerConfig,
|
||||||
clientRegistry *registry.ClientRegistry,
|
clientRegistry *registry.ClientRegistry,
|
||||||
pxyManager ProxyManager,
|
pxyManager ProxyManager,
|
||||||
ctlManager ControlManager,
|
|
||||||
) *Controller {
|
) *Controller {
|
||||||
return &Controller{
|
return &Controller{
|
||||||
serverCfg: serverCfg,
|
serverCfg: serverCfg,
|
||||||
clientRegistry: clientRegistry,
|
clientRegistry: clientRegistry,
|
||||||
pxyManager: pxyManager,
|
pxyManager: pxyManager,
|
||||||
ctlManager: ctlManager,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/serverinfo
|
// /api/serverinfo
|
||||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||||
|
return c.buildServerInfoResp(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||||
serverStats := mem.StatsCollector.GetServer()
|
serverStats := mem.StatsCollector.GetServer()
|
||||||
svrResp := model.ServerInfoResp{
|
return model.ServerInfoResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
BindPort: c.serverCfg.BindPort,
|
BindPort: c.serverCfg.BindPort,
|
||||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||||
@@ -87,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
|||||||
ClientCounts: serverStats.ClientCounts,
|
ClientCounts: serverStats.ClientCounts,
|
||||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
return svrResp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/clients
|
// /api/clients
|
||||||
@@ -225,24 +220,6 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
|
|||||||
return proxyInfo, nil
|
return proxyInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/proxy/:name/close
|
|
||||||
func (c *Controller) APICloseProxyByName(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.ctlManager == nil {
|
|
||||||
return nil, fmt.Errorf("control manager unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ctlManager.CloseAllProxyByName(name); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return httppkg.GeneralResponse{Code: 200, Msg: "ok"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/proxies?status=offline
|
// DELETE /api/proxies?status=offline
|
||||||
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||||
status := ctx.Query("status")
|
status := ctx.Query("status")
|
||||||
|
|||||||
+279
-26
@@ -17,22 +17,31 @@ package http
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
"github.com/fatedier/frp/server/http/model"
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultV2Page = 1
|
defaultV2Page = 1
|
||||||
defaultV2PageSize = 50
|
defaultV2PageSize = 50
|
||||||
maxV2PageSize = 200
|
maxV2PageSize = 200
|
||||||
|
|
||||||
|
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||||
|
v2ProxyTrafficDefaultDays = 7
|
||||||
|
v2ProxyTrafficUnit = "bytes"
|
||||||
|
v2ProxyTrafficGranularity = "day"
|
||||||
)
|
)
|
||||||
|
|
||||||
var apiV2ProxyTypes = []string{
|
var apiV2ProxyTypes = []string{
|
||||||
@@ -46,6 +55,55 @@ var apiV2ProxyTypes = []string{
|
|||||||
string(v1.ProxyTypeSUDP),
|
string(v1.ProxyTypeSUDP),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /api/v2/system/info
|
||||||
|
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
||||||
|
info := c.buildServerInfoResp()
|
||||||
|
proxyTypeCounts := info.ProxyTypeCounts
|
||||||
|
if proxyTypeCounts == nil {
|
||||||
|
proxyTypeCounts = map[string]int64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2SystemInfoResp{
|
||||||
|
Version: info.Version,
|
||||||
|
Config: model.V2SystemInfoConfigResp{
|
||||||
|
BindPort: info.BindPort,
|
||||||
|
VhostHTTPPort: info.VhostHTTPPort,
|
||||||
|
VhostHTTPSPort: info.VhostHTTPSPort,
|
||||||
|
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
||||||
|
KCPBindPort: info.KCPBindPort,
|
||||||
|
QUICBindPort: info.QUICBindPort,
|
||||||
|
SubdomainHost: info.SubdomainHost,
|
||||||
|
MaxPoolCount: info.MaxPoolCount,
|
||||||
|
MaxPortsPerClient: info.MaxPortsPerClient,
|
||||||
|
HeartbeatTimeout: info.HeartBeatTimeout,
|
||||||
|
AllowPortsStr: info.AllowPortsStr,
|
||||||
|
TLSForce: info.TLSForce,
|
||||||
|
},
|
||||||
|
Status: model.V2SystemInfoStatusResp{
|
||||||
|
TotalTrafficIn: info.TotalTrafficIn,
|
||||||
|
TotalTrafficOut: info.TotalTrafficOut,
|
||||||
|
CurConns: info.CurConns,
|
||||||
|
ClientCounts: info.ClientCounts,
|
||||||
|
ProxyTypeCounts: proxyTypeCounts,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/system/prune
|
||||||
|
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
||||||
|
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
||||||
|
return model.V2SystemPruneResp{
|
||||||
|
Type: pruneType,
|
||||||
|
Cleared: cleared,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// /api/v2/users
|
// /api/v2/users
|
||||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||||
page, pageSize, err := parseV2PageParams(ctx)
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
@@ -137,7 +195,26 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
|||||||
|
|
||||||
// /api/v2/clients/{key}
|
// /api/v2/clients/{key}
|
||||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||||
return c.APIClientDetail(ctx)
|
key, err := decodeV2PathParam(ctx, "key", "client key")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := c.clientRegistry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildClientInfoResp(info)
|
||||||
|
status := c.buildV2ClientStatus(info)
|
||||||
|
return model.V2ClientDetailResp{
|
||||||
|
ClientInfoResp: resp,
|
||||||
|
Status: status,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/v2/proxies
|
// /api/v2/proxies
|
||||||
@@ -179,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||||
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return cmp.Compare(a.Name, b.Name)
|
return cmp.Compare(a.Name, b.Name)
|
||||||
@@ -190,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
|||||||
|
|
||||||
// /api/v2/proxies/{name}
|
// /api/v2/proxies/{name}
|
||||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||||
name := ctx.Param("name")
|
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||||
if name == "" {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("missing proxy name")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := mem.StatsCollector.GetProxyByName(name)
|
ps := mem.StatsCollector.GetProxyByName(name)
|
||||||
@@ -202,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
|||||||
return c.buildV2ProxyResp(ps), nil
|
return c.buildV2ProxyResp(ps), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /api/v2/proxies/{name}/traffic
|
||||||
|
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||||
|
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||||
|
if proxyTrafficInfo == nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
||||||
|
raw := ctx.Param(key)
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("missing %s", label)
|
||||||
|
}
|
||||||
|
decoded, err := url.PathUnescape(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||||
item, ok := items[user]
|
item, ok := items[user]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -261,6 +365,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
|
|||||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseV2SystemPruneType(raw string) (string, error) {
|
||||||
|
pruneType := strings.ToLower(raw)
|
||||||
|
switch pruneType {
|
||||||
|
case "":
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
||||||
|
case v2SystemPruneTypeOfflineProxies:
|
||||||
|
return pruneType, nil
|
||||||
|
default:
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func matchV2StatusFilter(online bool, filter string) bool {
|
func matchV2StatusFilter(online bool, filter string) bool {
|
||||||
switch filter {
|
switch filter {
|
||||||
case "", "all":
|
case "", "all":
|
||||||
@@ -320,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
|||||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||||
values := []string{
|
values := []string{
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Type,
|
item.Spec.Type,
|
||||||
item.User,
|
item.User,
|
||||||
item.ClientID,
|
item.ClientID,
|
||||||
item.Status.State,
|
item.Status.State,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch spec := item.Spec.(type) {
|
switch item.Spec.Type {
|
||||||
case *model.TCPOutConf:
|
case string(v1.ProxyTypeTCP):
|
||||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
||||||
case *model.UDPOutConf:
|
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
||||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
}
|
||||||
case *model.HTTPOutConf:
|
case string(v1.ProxyTypeUDP):
|
||||||
values = append(values, spec.CustomDomains...)
|
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
||||||
values = append(values, spec.SubDomain)
|
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
||||||
case *model.HTTPSOutConf:
|
}
|
||||||
values = append(values, spec.CustomDomains...)
|
case string(v1.ProxyTypeHTTP):
|
||||||
values = append(values, spec.SubDomain)
|
if item.Spec.HTTP != nil {
|
||||||
case *model.TCPMuxOutConf:
|
values = append(values, item.Spec.HTTP.CustomDomains...)
|
||||||
values = append(values, spec.CustomDomains...)
|
values = append(values, item.Spec.HTTP.Subdomain)
|
||||||
values = append(values, spec.SubDomain)
|
}
|
||||||
|
case string(v1.ProxyTypeHTTPS):
|
||||||
|
if item.Spec.HTTPS != nil {
|
||||||
|
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.HTTPS.Subdomain)
|
||||||
|
}
|
||||||
|
case string(v1.ProxyTypeTCPMUX):
|
||||||
|
if item.Spec.TCPMux != nil {
|
||||||
|
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.TCPMux.Subdomain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return containsV2Query(q, values...)
|
return containsV2Query(q, values...)
|
||||||
@@ -366,29 +492,156 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||||
|
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||||
|
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||||
|
history = append(history, model.V2ProxyTrafficPointResp{
|
||||||
|
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||||
|
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||||
|
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2ProxyTrafficResp{
|
||||||
|
Name: name,
|
||||||
|
Unit: v2ProxyTrafficUnit,
|
||||||
|
Granularity: v2ProxyTrafficGranularity,
|
||||||
|
History: history,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||||
|
if todayFirstIndex >= len(values) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return values[todayFirstIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||||
|
status := model.V2ClientStatusResp{State: "offline"}
|
||||||
|
if info.Online {
|
||||||
|
status.State = "online"
|
||||||
|
}
|
||||||
|
|
||||||
|
user := info.User
|
||||||
|
clientID := info.ClientID()
|
||||||
|
for _, ps := range c.listV2ProxyStats("") {
|
||||||
|
if ps.User != user || ps.ClientID != clientID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.CurConns += ps.CurConns
|
||||||
|
status.ProxyCount++
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||||
state := "offline"
|
state := "offline"
|
||||||
var spec any
|
var cfg v1.ProxyConfigurer
|
||||||
if c.pxyManager != nil {
|
if c.pxyManager != nil {
|
||||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||||
state = "online"
|
state = "online"
|
||||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
cfg = pxy.GetConfigurer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.V2ProxyResp{
|
return model.V2ProxyResp{
|
||||||
Name: ps.Name,
|
Name: ps.Name,
|
||||||
Type: ps.Type,
|
|
||||||
User: ps.User,
|
User: ps.User,
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
Spec: spec,
|
Spec: buildV2ProxySpec(ps.Type, cfg),
|
||||||
Status: model.V2ProxyStatusResp{
|
Status: model.V2ProxyStatusResp{
|
||||||
State: state,
|
State: state,
|
||||||
TodayTrafficIn: ps.TodayTrafficIn,
|
TodayTrafficIn: ps.TodayTrafficIn,
|
||||||
TodayTrafficOut: ps.TodayTrafficOut,
|
TodayTrafficOut: ps.TodayTrafficOut,
|
||||||
CurConns: ps.CurConns,
|
CurConns: ps.CurConns,
|
||||||
LastStartTime: ps.LastStartTime,
|
LastStartAt: ps.LastStartAt,
|
||||||
LastCloseTime: ps.LastCloseTime,
|
LastCloseAt: ps.LastCloseAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
||||||
|
spec := model.V2ProxySpec{Type: proxyType}
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case string(v1.ProxyTypeTCP):
|
||||||
|
block := &model.V2TCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.RemotePort = &c.RemotePort
|
||||||
|
}
|
||||||
|
spec.TCP = block
|
||||||
|
case string(v1.ProxyTypeUDP):
|
||||||
|
block := &model.V2UDPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.RemotePort = &c.RemotePort
|
||||||
|
}
|
||||||
|
spec.UDP = block
|
||||||
|
case string(v1.ProxyTypeHTTP):
|
||||||
|
block := &model.V2HTTPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
block.Locations = slices.Clone(c.Locations)
|
||||||
|
block.HostHeaderRewrite = c.HostHeaderRewrite
|
||||||
|
}
|
||||||
|
spec.HTTP = block
|
||||||
|
case string(v1.ProxyTypeHTTPS):
|
||||||
|
block := &model.V2HTTPSProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
}
|
||||||
|
spec.HTTPS = block
|
||||||
|
case string(v1.ProxyTypeTCPMUX):
|
||||||
|
block := &model.V2TCPMuxProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
block.Multiplexer = c.Multiplexer
|
||||||
|
block.RouteByHTTPUser = c.RouteByHTTPUser
|
||||||
|
}
|
||||||
|
spec.TCPMux = block
|
||||||
|
case string(v1.ProxyTypeSTCP):
|
||||||
|
block := &model.V2STCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.STCP = block
|
||||||
|
case string(v1.ProxyTypeSUDP):
|
||||||
|
block := &model.V2SUDPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.SUDP = block
|
||||||
|
case string(v1.ProxyTypeXTCP):
|
||||||
|
block := &model.V2XTCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.XTCP = block
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
||||||
|
return model.V2ProxyBaseSpec{
|
||||||
|
Annotations: maps.Clone(base.Annotations),
|
||||||
|
Metadatas: maps.Clone(base.Metadatas),
|
||||||
|
Transport: &model.V2ProxyTransportSpec{
|
||||||
|
UseEncryption: base.Transport.UseEncryption,
|
||||||
|
UseCompression: base.Transport.UseCompression,
|
||||||
|
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
||||||
|
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
||||||
|
},
|
||||||
|
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
||||||
|
Group: base.LoadBalancer.Group,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configtypes "github.com/fatedier/frp/pkg/config/types"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
proxyType string
|
||||||
|
cfg v1.ProxyConfigurer
|
||||||
|
blockKeys []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
proxyType: "tcp",
|
||||||
|
cfg: &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
|
||||||
|
RemotePort: 6000,
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "udp",
|
||||||
|
cfg: &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||||
|
RemotePort: 7000,
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "http",
|
||||||
|
cfg: &v1.HTTPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
|
||||||
|
Locations: []string{"/api"},
|
||||||
|
HTTPUser: "secret-http-user",
|
||||||
|
HTTPPassword: "secret-http-password",
|
||||||
|
HostHeaderRewrite: "backend.example.com",
|
||||||
|
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
|
||||||
|
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
|
||||||
|
RouteByHTTPUser: "secret-http-route-user",
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "https",
|
||||||
|
cfg: &v1.HTTPSProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "tcpmux",
|
||||||
|
cfg: &v1.TCPMuxProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
|
||||||
|
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
|
||||||
|
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
|
||||||
|
RouteByHTTPUser: "displayed-mux-user",
|
||||||
|
Multiplexer: "httpconnect",
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "stcp",
|
||||||
|
cfg: &v1.STCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "sudp",
|
||||||
|
cfg: &v1.SUDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "xtcp",
|
||||||
|
cfg: &v1.XTCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.proxyType, func(t *testing.T) {
|
||||||
|
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
|
||||||
|
raw := mustMarshalJSON(t, spec)
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
|
||||||
|
|
||||||
|
var gotType string
|
||||||
|
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
|
||||||
|
t.Fatalf("unmarshal spec type failed: %v", err)
|
||||||
|
}
|
||||||
|
if gotType != tt.proxyType {
|
||||||
|
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var block map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal active block failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, block, tt.blockKeys...)
|
||||||
|
assertV2ProxyCommonSpec(t, block)
|
||||||
|
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
|
||||||
|
assertNoV2ProxySensitiveFields(t, block)
|
||||||
|
|
||||||
|
content := string(raw)
|
||||||
|
for _, secret := range []string{
|
||||||
|
"secret-proxy-name",
|
||||||
|
"secret-group-key",
|
||||||
|
"secret-local-host",
|
||||||
|
"secret-plugin-user",
|
||||||
|
"secret-plugin-password",
|
||||||
|
"secret-health-path",
|
||||||
|
"secret-http-user",
|
||||||
|
"secret-http-password",
|
||||||
|
"secret-request-header",
|
||||||
|
"secret-response-header",
|
||||||
|
"secret-http-route-user",
|
||||||
|
"secret-mux-http-user",
|
||||||
|
"secret-mux-http-password",
|
||||||
|
"secret-stcp-key",
|
||||||
|
"secret-stcp-user",
|
||||||
|
"secret-sudp-key",
|
||||||
|
"secret-sudp-user",
|
||||||
|
"secret-xtcp-key",
|
||||||
|
"secret-xtcp-user",
|
||||||
|
} {
|
||||||
|
if strings.Contains(content, secret) {
|
||||||
|
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case "tcp":
|
||||||
|
var block model.V2TCPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal tcp block failed: %v", err)
|
||||||
|
}
|
||||||
|
if block.RemotePort == nil || *block.RemotePort != 6000 {
|
||||||
|
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
|
||||||
|
}
|
||||||
|
case "udp":
|
||||||
|
var block model.V2UDPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal udp block failed: %v", err)
|
||||||
|
}
|
||||||
|
if block.RemotePort == nil || *block.RemotePort != 7000 {
|
||||||
|
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
|
||||||
|
}
|
||||||
|
case "http":
|
||||||
|
var block model.V2HTTPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal http block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
|
||||||
|
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
|
||||||
|
block.HostHeaderRewrite != "backend.example.com" {
|
||||||
|
t.Fatalf("http fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
case "https":
|
||||||
|
var block model.V2HTTPSProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal https block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
|
||||||
|
t.Fatalf("https fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
case "tcpmux":
|
||||||
|
var block model.V2TCPMuxProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal tcpmux block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
|
||||||
|
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
|
||||||
|
t.Fatalf("tcpmux fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
|
||||||
|
for _, proxyType := range apiV2ProxyTypes {
|
||||||
|
t.Run(proxyType, func(t *testing.T) {
|
||||||
|
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
|
||||||
|
Name: "offline-" + proxyType,
|
||||||
|
Type: proxyType,
|
||||||
|
})
|
||||||
|
if resp.Status.State != "offline" {
|
||||||
|
t.Fatalf("offline phase mismatch: %#v", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal offline spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, proxyType, "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, specObject[proxyType])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
|
||||||
|
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||||
|
RemotePort: 7000,
|
||||||
|
})
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal mismatched spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, specObject["tcp"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create bandwidth limit failed: %v", err)
|
||||||
|
}
|
||||||
|
enabled := false
|
||||||
|
return v1.ProxyBaseConfig{
|
||||||
|
Name: "secret-proxy-name",
|
||||||
|
Type: proxyType,
|
||||||
|
Enabled: &enabled,
|
||||||
|
Annotations: map[string]string{"annotation-key": "annotation-value"},
|
||||||
|
Metadatas: map[string]string{"metadata-key": "metadata-value"},
|
||||||
|
Transport: v1.ProxyTransport{
|
||||||
|
UseEncryption: true,
|
||||||
|
UseCompression: true,
|
||||||
|
BandwidthLimit: bandwidthLimit,
|
||||||
|
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
|
||||||
|
ProxyProtocolVersion: "v2",
|
||||||
|
},
|
||||||
|
LoadBalancer: v1.LoadBalancerConfig{
|
||||||
|
Group: "public-group",
|
||||||
|
GroupKey: "secret-group-key",
|
||||||
|
},
|
||||||
|
HealthCheck: v1.HealthCheckConfig{
|
||||||
|
Type: "http",
|
||||||
|
Path: "secret-health-path",
|
||||||
|
},
|
||||||
|
ProxyBackend: v1.ProxyBackend{
|
||||||
|
LocalIP: "secret-local-host",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Plugin: v1.TypedClientPluginOptions{
|
||||||
|
Type: v1.PluginHTTPProxy,
|
||||||
|
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
|
||||||
|
Type: v1.PluginHTTPProxy,
|
||||||
|
HTTPUser: "secret-plugin-user",
|
||||||
|
HTTPPassword: "secret-plugin-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var annotations map[string]string
|
||||||
|
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
|
||||||
|
t.Fatalf("unmarshal annotations failed: %v", err)
|
||||||
|
}
|
||||||
|
if annotations["annotation-key"] != "annotation-value" {
|
||||||
|
t.Fatalf("annotations mismatch: %#v", annotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadatas map[string]string
|
||||||
|
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
|
||||||
|
t.Fatalf("unmarshal metadatas failed: %v", err)
|
||||||
|
}
|
||||||
|
if metadatas["metadata-key"] != "metadata-value" {
|
||||||
|
t.Fatalf("metadatas mismatch: %#v", metadatas)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRawJSONKeysFromMessage(t, block["transport"],
|
||||||
|
"bandwidthLimit",
|
||||||
|
"bandwidthLimitMode",
|
||||||
|
"useCompression",
|
||||||
|
"useEncryption",
|
||||||
|
)
|
||||||
|
var transport model.V2ProxyTransportSpec
|
||||||
|
if err := json.Unmarshal(block["transport"], &transport); err != nil {
|
||||||
|
t.Fatalf("unmarshal transport failed: %v", err)
|
||||||
|
}
|
||||||
|
if !transport.UseEncryption || !transport.UseCompression ||
|
||||||
|
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
|
||||||
|
t.Fatalf("transport mismatch: %#v", transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
|
||||||
|
var loadBalancer model.V2ProxyLoadBalancerSpec
|
||||||
|
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
|
||||||
|
t.Fatalf("unmarshal load balancer failed: %v", err)
|
||||||
|
}
|
||||||
|
if loadBalancer.Group != "public-group" {
|
||||||
|
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
forbidden := map[string]struct{}{
|
||||||
|
"allowUsers": {},
|
||||||
|
"enabled": {},
|
||||||
|
"groupKey": {},
|
||||||
|
"healthCheck": {},
|
||||||
|
"httpPassword": {},
|
||||||
|
"httpUser": {},
|
||||||
|
"localIP": {},
|
||||||
|
"localPort": {},
|
||||||
|
"name": {},
|
||||||
|
"natTraversal": {},
|
||||||
|
"plugin": {},
|
||||||
|
"proxyProtocolVersion": {},
|
||||||
|
"requestHeaders": {},
|
||||||
|
"responseHeaders": {},
|
||||||
|
"secretKey": {},
|
||||||
|
"type": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(current any) {
|
||||||
|
switch current := current.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, nested := range current {
|
||||||
|
if _, ok := forbidden[key]; ok {
|
||||||
|
t.Fatalf("sensitive field %q leaked in active block", key)
|
||||||
|
}
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range current {
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal active block failed: %v", err)
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode active block failed: %v", err)
|
||||||
|
}
|
||||||
|
walk(decoded)
|
||||||
|
}
|
||||||
@@ -20,10 +20,13 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
@@ -39,10 +42,16 @@ type v2EnvelopeForTest[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeStatsCollector struct {
|
type fakeStatsCollector struct {
|
||||||
proxies map[string]*mem.ProxyStats
|
server *mem.ServerStats
|
||||||
|
proxies map[string]*mem.ProxyStats
|
||||||
|
traffic map[string]*mem.ProxyTrafficInfo
|
||||||
|
pruneable map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||||
|
if f.server != nil {
|
||||||
|
return f.server
|
||||||
|
}
|
||||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +78,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||||
return nil
|
return f.traffic[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||||
return 0, len(f.proxies)
|
return 0, len(f.proxies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
||||||
|
total := len(f.proxies)
|
||||||
|
cleared := 0
|
||||||
|
for name := range f.pruneable {
|
||||||
|
if _, ok := f.proxies[name]; ok {
|
||||||
|
delete(f.proxies, name)
|
||||||
|
cleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.pruneable = map[string]bool{}
|
||||||
|
return cleared, total
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
server: &mem.ServerStats{
|
||||||
|
TotalTrafficIn: 1024,
|
||||||
|
TotalTrafficOut: 2048,
|
||||||
|
CurConns: 3,
|
||||||
|
ClientCounts: 4,
|
||||||
|
ProxyTypeCounts: map[string]int64{
|
||||||
|
"tcp": 2,
|
||||||
|
"http": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxies: map[string]*mem.ProxyStats{},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{
|
||||||
|
BindPort: 7000,
|
||||||
|
VhostHTTPPort: 8080,
|
||||||
|
VhostHTTPSPort: 8443,
|
||||||
|
TCPMuxHTTPConnectPort: 9000,
|
||||||
|
KCPBindPort: 7001,
|
||||||
|
QUICBindPort: 7002,
|
||||||
|
SubDomainHost: "example.com",
|
||||||
|
MaxPortsPerClient: 8,
|
||||||
|
AllowPorts: []types.PortsRange{
|
||||||
|
{Start: 1000, End: 1002},
|
||||||
|
{Single: 2000},
|
||||||
|
},
|
||||||
|
Transport: v1.ServerTransportConfig{
|
||||||
|
MaxPoolCount: 5,
|
||||||
|
HeartbeatTimeout: 90,
|
||||||
|
TLS: v1.TLSServerConfig{
|
||||||
|
Force: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/system/info")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
||||||
|
"allowPortsStr",
|
||||||
|
"bindPort",
|
||||||
|
"heartbeatTimeout",
|
||||||
|
"kcpBindPort",
|
||||||
|
"maxPoolCount",
|
||||||
|
"maxPortsPerClient",
|
||||||
|
"quicBindPort",
|
||||||
|
"subdomainHost",
|
||||||
|
"tcpmuxHTTPConnectPort",
|
||||||
|
"tlsForce",
|
||||||
|
"vhostHTTPPort",
|
||||||
|
"vhostHTTPSPort",
|
||||||
|
)
|
||||||
|
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
||||||
|
"clientCounts",
|
||||||
|
"curConns",
|
||||||
|
"proxyTypeCount",
|
||||||
|
"totalTrafficIn",
|
||||||
|
"totalTrafficOut",
|
||||||
|
)
|
||||||
|
|
||||||
|
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
||||||
|
if systemResp.Data.Version == "" {
|
||||||
|
t.Fatal("version should be set at top level")
|
||||||
|
}
|
||||||
|
if systemResp.Data.Config.BindPort != 7000 ||
|
||||||
|
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
||||||
|
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
||||||
|
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
||||||
|
systemResp.Data.Config.KCPBindPort != 7001 ||
|
||||||
|
systemResp.Data.Config.QUICBindPort != 7002 ||
|
||||||
|
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
||||||
|
systemResp.Data.Config.MaxPoolCount != 5 ||
|
||||||
|
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
||||||
|
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
||||||
|
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
||||||
|
!systemResp.Data.Config.TLSForce {
|
||||||
|
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
||||||
|
}
|
||||||
|
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
||||||
|
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
||||||
|
systemResp.Data.Status.CurConns != 3 ||
|
||||||
|
systemResp.Data.Status.ClientCounts != 4 ||
|
||||||
|
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
||||||
|
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
||||||
|
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
collector := &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
||||||
|
"http-offline": {Name: "http-offline", Type: "http"},
|
||||||
|
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
||||||
|
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
||||||
|
"http-online": {Name: "http-online", Type: "http"},
|
||||||
|
"udp-online": {Name: "udp-online", Type: "udp"},
|
||||||
|
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
||||||
|
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
||||||
|
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
||||||
|
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
||||||
|
},
|
||||||
|
pruneable: map[string]bool{
|
||||||
|
"tcp-offline": true,
|
||||||
|
"http-offline": true,
|
||||||
|
"udp-offline": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mem.StatsCollector = collector
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
||||||
|
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||||
|
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
||||||
|
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
||||||
|
}
|
||||||
|
if _, ok := collector.proxies["tcp-offline"]; ok {
|
||||||
|
t.Fatal("pruned proxy statistics should be removed")
|
||||||
|
}
|
||||||
|
if _, ok := collector.proxies["tcp-online"]; !ok {
|
||||||
|
t.Fatal("online proxy statistics should remain")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||||
|
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
||||||
|
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
||||||
|
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
||||||
|
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||||
controller := newV2TestController(t)
|
controller := newV2TestController(t)
|
||||||
router := newV2TestRouter(controller)
|
router := newV2TestRouter(controller)
|
||||||
@@ -146,10 +352,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
|||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
}
|
}
|
||||||
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
|
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||||
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
||||||
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
||||||
}
|
}
|
||||||
|
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
||||||
|
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"tcp-url": {
|
||||||
|
Name: "tcp-url",
|
||||||
|
Type: "tcp",
|
||||||
|
User: "url",
|
||||||
|
ClientID: "client/a?b#c",
|
||||||
|
CurConns: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
||||||
|
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
encodedKey := url.PathEscape("url.client/a?b#c")
|
||||||
|
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||||
|
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
||||||
|
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
||||||
|
}
|
||||||
|
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
||||||
|
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||||
@@ -171,28 +416,194 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
|||||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||||
}
|
}
|
||||||
proxyItem := proxyResp.Data.Items[0]
|
proxyItem := proxyResp.Data.Items[0]
|
||||||
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||||
}
|
}
|
||||||
|
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
||||||
|
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
||||||
|
var rawListSpec map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
||||||
|
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||||
|
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||||
|
"curConns",
|
||||||
|
"lastCloseAt",
|
||||||
|
"lastStartAt",
|
||||||
|
"phase",
|
||||||
|
"todayTrafficIn",
|
||||||
|
"todayTrafficOut",
|
||||||
|
)
|
||||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||||
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
||||||
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
||||||
}
|
}
|
||||||
|
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
||||||
|
var rawDetailSpec map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
||||||
|
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
||||||
|
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
||||||
|
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
||||||
|
}
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||||
if userResp.Data.Total != 3 {
|
if userResp.Data.Total != 3 {
|
||||||
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||||
}
|
}
|
||||||
|
expectedProxyCounts := map[string]int{
|
||||||
|
"": 1,
|
||||||
|
"alice": 2,
|
||||||
|
"bob": 1,
|
||||||
|
}
|
||||||
for _, item := range userResp.Data.Items {
|
for _, item := range userResp.Data.Items {
|
||||||
if item.ClientCount != 1 || item.ProxyCount != 1 {
|
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
||||||
t.Fatalf("user counts mismatch: %#v", item)
|
t.Fatalf("user counts mismatch: %#v", item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"ssh": {Name: "ssh", Type: "tcp"},
|
||||||
|
},
|
||||||
|
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||||
|
"ssh": {
|
||||||
|
Name: "ssh",
|
||||||
|
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
||||||
|
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
||||||
|
|
||||||
|
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||||
|
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
||||||
|
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
||||||
|
}
|
||||||
|
if len(trafficResp.Data.History) != 7 {
|
||||||
|
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
||||||
|
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
||||||
|
var prevDate time.Time
|
||||||
|
for i, point := range trafficResp.Data.History {
|
||||||
|
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
||||||
|
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
||||||
|
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
||||||
|
}
|
||||||
|
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
||||||
|
}
|
||||||
|
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
||||||
|
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
||||||
|
}
|
||||||
|
prevDate = parsedDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
||||||
|
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
||||||
|
name := "folder/ssh?x#y"
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
name: {Name: name, Type: "tcp", User: "encoded"},
|
||||||
|
},
|
||||||
|
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||||
|
name: {
|
||||||
|
Name: name,
|
||||||
|
TrafficIn: []int64{1},
|
||||||
|
TrafficOut: []int64{2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
encodedName := url.PathEscape(name)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||||
|
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
||||||
|
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||||
|
if trafficResp.Data.Name != name {
|
||||||
|
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
||||||
|
}
|
||||||
|
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
||||||
|
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
||||||
|
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -202,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "tcp remote port",
|
name: "tcp remote port",
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
RemotePort: 6000,
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||||
}},
|
}},
|
||||||
q: "6000",
|
q: "6000",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "udp remote port",
|
name: "udp remote port",
|
||||||
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||||
RemotePort: 7000,
|
Type: "udp",
|
||||||
|
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||||
}},
|
}},
|
||||||
q: "7000",
|
q: "7000",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "remote port does not match colon form",
|
name: "remote port does not match colon form",
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
RemotePort: 6000,
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||||
}},
|
}},
|
||||||
q: ":6000",
|
q: ":6000",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "http custom domain",
|
name: "http custom domain",
|
||||||
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
Type: "http",
|
||||||
|
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
||||||
}},
|
}},
|
||||||
q: "app.example.com",
|
q: "app.example.com",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "https subdomain",
|
name: "https subdomain",
|
||||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
Type: "https",
|
||||||
|
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||||
}},
|
}},
|
||||||
q: "portal",
|
q: "portal",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "subdomain does not match expanded host",
|
name: "subdomain does not match expanded host",
|
||||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
Type: "https",
|
||||||
|
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||||
}},
|
}},
|
||||||
q: "portal.example.com",
|
q: "portal.example.com",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tcpmux custom domain",
|
name: "tcpmux custom domain",
|
||||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
Type: "tcpmux",
|
||||||
|
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
||||||
}},
|
}},
|
||||||
q: "mux.example.com",
|
q: "mux.example.com",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nil spec does not match spec fields",
|
name: "offline shell does not match online spec fields",
|
||||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{},
|
||||||
|
}},
|
||||||
q: "6000",
|
q: "6000",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "offline shell does not contribute zero remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{},
|
||||||
|
}},
|
||||||
|
q: "0",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -277,7 +707,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|||||||
controller := newV2TestController(t)
|
controller := newV2TestController(t)
|
||||||
router := newV2TestRouter(controller)
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
resp := performRequest(router, "/api/clients")
|
resp := performRequest(router, "/api/serverinfo")
|
||||||
|
var serverInfo model.ServerInfoResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
||||||
|
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if serverInfo.Version == "" {
|
||||||
|
t.Fatal("legacy serverinfo version should be set")
|
||||||
|
}
|
||||||
|
var serverInfoRaw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
||||||
|
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := serverInfoRaw["data"]; ok {
|
||||||
|
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := serverInfoRaw["config"]; ok {
|
||||||
|
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/clients")
|
||||||
var clients []model.ClientInfoResp
|
var clients []model.ClientInfoResp
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
||||||
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
||||||
@@ -298,6 +747,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|||||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||||
|
var traffic model.GetProxyTrafficResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||||
|
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if traffic.Name != "tcp-alice" ||
|
||||||
|
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||||
|
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||||
|
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||||
|
}
|
||||||
|
var trafficRaw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||||
|
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := trafficRaw["data"]; ok {
|
||||||
|
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func v2TestIntPtr(value int) *int {
|
||||||
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
func newV2TestController(t *testing.T) *Controller {
|
func newV2TestController(t *testing.T) *Controller {
|
||||||
@@ -322,6 +793,18 @@ func newV2TestController(t *testing.T) *Controller {
|
|||||||
ClientID: "client-a",
|
ClientID: "client-a",
|
||||||
TodayTrafficIn: 30,
|
TodayTrafficIn: 30,
|
||||||
TodayTrafficOut: 40,
|
TodayTrafficOut: 40,
|
||||||
|
CurConns: 2,
|
||||||
|
LastStartTime: "07-08 12:30:00",
|
||||||
|
LastCloseTime: "07-08 12:31:40",
|
||||||
|
LastStartAt: 1783504200,
|
||||||
|
LastCloseAt: 1783504300,
|
||||||
|
},
|
||||||
|
"http-alice": {
|
||||||
|
Name: "http-alice",
|
||||||
|
Type: "http",
|
||||||
|
User: "alice",
|
||||||
|
ClientID: "client-a",
|
||||||
|
CurConns: 3,
|
||||||
},
|
},
|
||||||
"udp-bob": {
|
"udp-bob": {
|
||||||
Name: "udp-bob",
|
Name: "udp-bob",
|
||||||
@@ -330,6 +813,13 @@ func newV2TestController(t *testing.T) *Controller {
|
|||||||
ClientID: "client-b",
|
ClientID: "client-b",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||||
|
"tcp-alice": {
|
||||||
|
Name: "tcp-alice",
|
||||||
|
TrafficIn: []int64{7, 6},
|
||||||
|
TrafficOut: []int64{70, 60},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
mem.StatsCollector = oldStatsCollector
|
mem.StatsCollector = oldStatsCollector
|
||||||
@@ -341,23 +831,34 @@ func newV2TestController(t *testing.T) *Controller {
|
|||||||
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
||||||
clientRegistry.MarkOfflineByRunID("run-b")
|
clientRegistry.MarkOfflineByRunID("run-b")
|
||||||
|
|
||||||
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), nil)
|
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||||
}
|
}
|
||||||
|
|
||||||
func newV2TestRouter(controller *Controller) *mux.Router {
|
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
||||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
encodedPathRouter := router.NewRoute().Subrouter()
|
||||||
|
encodedPathRouter.UseEncodedPath()
|
||||||
|
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
||||||
|
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
return performRequestWithMethod(handler, http.MethodGet, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, target, nil)
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(resp, req)
|
handler.ServeHTTP(resp, req)
|
||||||
return resp
|
return resp
|
||||||
@@ -372,3 +873,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if len(raw) != len(want) {
|
||||||
|
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
|
||||||
|
}
|
||||||
|
for _, key := range want {
|
||||||
|
if _, ok := raw[key]; !ok {
|
||||||
|
t.Fatalf("json key %q missing from %v", key, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, out, want...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
out, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal json failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
+137
-4
@@ -21,26 +21,159 @@ type V2PageResp[T any] struct {
|
|||||||
Items []T `json:"items"`
|
Items []T `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2SystemInfoResp struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Config V2SystemInfoConfigResp `json:"config"`
|
||||||
|
Status V2SystemInfoStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SystemInfoConfigResp struct {
|
||||||
|
BindPort int `json:"bindPort"`
|
||||||
|
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||||
|
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||||
|
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||||
|
KCPBindPort int `json:"kcpBindPort"`
|
||||||
|
QUICBindPort int `json:"quicBindPort"`
|
||||||
|
SubdomainHost string `json:"subdomainHost"`
|
||||||
|
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||||
|
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||||
|
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||||
|
AllowPortsStr string `json:"allowPortsStr"`
|
||||||
|
TLSForce bool `json:"tlsForce"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SystemInfoStatusResp struct {
|
||||||
|
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||||
|
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
ClientCounts int64 `json:"clientCounts"`
|
||||||
|
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SystemPruneResp struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cleared int `json:"cleared"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
type V2UserResp struct {
|
type V2UserResp struct {
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
ClientCount int `json:"clientCount"`
|
ClientCount int `json:"clientCount"`
|
||||||
ProxyCount int `json:"proxyCount"`
|
ProxyCount int `json:"proxyCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2ClientDetailResp struct {
|
||||||
|
ClientInfoResp
|
||||||
|
Status V2ClientStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ClientStatusResp struct {
|
||||||
|
State string `json:"phase"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
ProxyCount int64 `json:"proxyCount"`
|
||||||
|
}
|
||||||
|
|
||||||
type V2ProxyResp struct {
|
type V2ProxyResp struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
ClientID string `json:"clientID"`
|
ClientID string `json:"clientID"`
|
||||||
Spec any `json:"spec"`
|
Spec V2ProxySpec `json:"spec"`
|
||||||
Status V2ProxyStatusResp `json:"status"`
|
Status V2ProxyStatusResp `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2ProxySpec struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
||||||
|
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
||||||
|
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
||||||
|
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
||||||
|
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
||||||
|
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
||||||
|
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
||||||
|
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyBaseSpec struct {
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||||
|
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
||||||
|
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTransportSpec struct {
|
||||||
|
UseEncryption bool `json:"useEncryption"`
|
||||||
|
UseCompression bool `json:"useCompression"`
|
||||||
|
BandwidthLimit string `json:"bandwidthLimit"`
|
||||||
|
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyLoadBalancerSpec struct {
|
||||||
|
Group string `json:"group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2TCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
RemotePort *int `json:"remotePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2UDPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
RemotePort *int `json:"remotePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2HTTPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Locations []string `json:"locations,omitempty"`
|
||||||
|
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2HTTPSProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2TCPMuxProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Multiplexer string `json:"multiplexer,omitempty"`
|
||||||
|
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2STCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SUDPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2XTCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
type V2ProxyStatusResp struct {
|
type V2ProxyStatusResp struct {
|
||||||
State string `json:"phase"`
|
State string `json:"phase"`
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||||
CurConns int64 `json:"curConns"`
|
CurConns int64 `json:"curConns"`
|
||||||
LastStartTime string `json:"lastStartTime"`
|
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||||
LastCloseTime string `json:"lastCloseTime"`
|
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTrafficResp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Granularity string `json:"granularity"`
|
||||||
|
History []V2ProxyTrafficPointResp `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTrafficPointResp struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
TrafficIn int64 `json:"trafficIn"`
|
||||||
|
TrafficOut int64 `json:"trafficOut"`
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-13
@@ -144,26 +144,18 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
name := pxy.GetName()
|
|
||||||
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
|
||||||
rwc = wrapCountingReadWriteCloser(rwc, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficOut(name, proxyType, bytes)
|
|
||||||
}, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficIn(name, proxyType, bytes)
|
|
||||||
})
|
|
||||||
|
|
||||||
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
||||||
workConn = netpkg.WrapCloseNotifyConn(workConn, func(error) {
|
workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)
|
||||||
pxy.updateStatsAfterClosedConn()
|
metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||||
})
|
|
||||||
metrics.Server.OpenConnection(name, proxyType)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *HTTPProxy) updateStatsAfterClosedConn() {
|
func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {
|
||||||
name := pxy.GetName()
|
name := pxy.GetName()
|
||||||
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
||||||
metrics.Server.CloseConnection(name, proxyType)
|
metrics.Server.CloseConnection(name, proxyType)
|
||||||
|
metrics.Server.AddTrafficIn(name, proxyType, totalWrite)
|
||||||
|
metrics.Server.AddTrafficOut(name, proxyType, totalRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *HTTPProxy) Close() {
|
func (pxy *HTTPProxy) Close() {
|
||||||
|
|||||||
+3
-12
@@ -317,20 +317,11 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
|||||||
|
|
||||||
name := pxy.GetName()
|
name := pxy.GetName()
|
||||||
proxyType := cfg.Type
|
proxyType := cfg.Type
|
||||||
local = wrapCountingReadWriteCloser(local, nil, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficIn(name, proxyType, bytes)
|
|
||||||
})
|
|
||||||
userConn = netpkg.WrapReadWriteCloserToConn(
|
|
||||||
wrapCountingReadWriteCloser(userConn, nil, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficOut(name, proxyType, bytes)
|
|
||||||
}),
|
|
||||||
userConn,
|
|
||||||
)
|
|
||||||
metrics.Server.OpenConnection(name, proxyType)
|
metrics.Server.OpenConnection(name, proxyType)
|
||||||
// Traffic is counted incrementally via the counting wrappers above, so the
|
inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
|
||||||
// byte totals returned by joinUserConnection are intentionally discarded here.
|
|
||||||
_, _, _ = pxy.joinUserConnection(local, userConn, proxyType, xl)
|
|
||||||
metrics.Server.CloseConnection(name, proxyType)
|
metrics.Server.CloseConnection(name, proxyType)
|
||||||
|
metrics.Server.AddTrafficIn(name, proxyType, inCount)
|
||||||
|
metrics.Server.AddTrafficOut(name, proxyType, outCount)
|
||||||
xl.Debugf("join connections closed")
|
xl.Debugf("join connections closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import "io"
|
|
||||||
|
|
||||||
type countingReadWriteCloser struct {
|
|
||||||
io.ReadWriteCloser
|
|
||||||
onRead func(int64)
|
|
||||||
onWrite func(int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapCountingReadWriteCloser(rwc io.ReadWriteCloser, onRead, onWrite func(int64)) io.ReadWriteCloser {
|
|
||||||
if onRead == nil && onWrite == nil {
|
|
||||||
return rwc
|
|
||||||
}
|
|
||||||
return &countingReadWriteCloser{
|
|
||||||
ReadWriteCloser: rwc,
|
|
||||||
onRead: onRead,
|
|
||||||
onWrite: onWrite,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *countingReadWriteCloser) Read(p []byte) (n int, err error) {
|
|
||||||
n, err = c.ReadWriteCloser.Read(p)
|
|
||||||
if n > 0 && c.onRead != nil {
|
|
||||||
c.onRead(int64(n))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *countingReadWriteCloser) Write(p []byte) (n int, err error) {
|
|
||||||
n, err = c.ReadWriteCloser.Write(p)
|
|
||||||
if n > 0 && c.onWrite != nil {
|
|
||||||
c.onWrite(int64(n))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,7 @@ type ClientInfo struct {
|
|||||||
User string
|
User string
|
||||||
RawClientID string
|
RawClientID string
|
||||||
RunID string
|
RunID string
|
||||||
|
ControlID uint64
|
||||||
Hostname string
|
Hostname string
|
||||||
IP string
|
IP string
|
||||||
Version string
|
Version string
|
||||||
@@ -64,6 +65,16 @@ func newClientRegistryWithClock(clk clock.PassiveClock) *ClientRegistry {
|
|||||||
|
|
||||||
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
||||||
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string) (key string, conflict bool) {
|
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string) (key string, conflict bool) {
|
||||||
|
return cr.RegisterWithControlID(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWithControlID is the generation-aware form used by ControlManager.
|
||||||
|
// A control ID is process-local and prevents an older control generation from
|
||||||
|
// changing the registry entry now owned by a newer generation with the same run ID.
|
||||||
|
func (cr *ClientRegistry) RegisterWithControlID(
|
||||||
|
user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string,
|
||||||
|
controlID uint64,
|
||||||
|
) (key string, conflict bool) {
|
||||||
if runID == "" {
|
if runID == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -83,6 +94,16 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
|||||||
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
|
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
|
||||||
return key, true
|
return key, true
|
||||||
}
|
}
|
||||||
|
if previousKey, ok := cr.runIndex[runID]; ok && previousKey != key {
|
||||||
|
if previous, ok := cr.clients[previousKey]; ok && previous.RunID == runID {
|
||||||
|
if previous.RawClientID == "" {
|
||||||
|
delete(cr.clients, previousKey)
|
||||||
|
} else {
|
||||||
|
setClientOffline(previous, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(cr.runIndex, runID)
|
||||||
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
info = &ClientInfo{
|
info = &ClientInfo{
|
||||||
@@ -97,6 +118,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
|||||||
|
|
||||||
info.RawClientID = rawClientID
|
info.RawClientID = rawClientID
|
||||||
info.RunID = runID
|
info.RunID = runID
|
||||||
|
info.ControlID = controlID
|
||||||
info.Hostname = hostname
|
info.Hostname = hostname
|
||||||
info.IP = remoteAddr
|
info.IP = remoteAddr
|
||||||
info.Version = version
|
info.Version = version
|
||||||
@@ -114,6 +136,16 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
|||||||
|
|
||||||
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
|
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
|
||||||
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
||||||
|
cr.markOfflineByRunID(runID, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkOfflineByRunIDAndControlID marks a client offline only when the registry
|
||||||
|
// entry still belongs to the supplied control generation.
|
||||||
|
func (cr *ClientRegistry) MarkOfflineByRunIDAndControlID(runID string, controlID uint64) {
|
||||||
|
cr.markOfflineByRunID(runID, controlID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *ClientRegistry) markOfflineByRunID(runID string, controlID uint64, matchControlID bool) {
|
||||||
cr.mu.Lock()
|
cr.mu.Lock()
|
||||||
defer cr.mu.Unlock()
|
defer cr.mu.Unlock()
|
||||||
|
|
||||||
@@ -121,17 +153,23 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if info, ok := cr.clients[key]; ok && info.RunID == runID {
|
if info, ok := cr.clients[key]; ok && info.RunID == runID && (!matchControlID || info.ControlID == controlID) {
|
||||||
if info.RawClientID == "" {
|
if info.RawClientID == "" {
|
||||||
delete(cr.clients, key)
|
delete(cr.clients, key)
|
||||||
} else {
|
} else {
|
||||||
info.RunID = ""
|
setClientOffline(info, cr.clock.Now())
|
||||||
info.Online = false
|
|
||||||
now := cr.clock.Now()
|
|
||||||
info.DisconnectedAt = now
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(cr.runIndex, runID)
|
if info, ok := cr.clients[key]; !ok || info.RunID != runID {
|
||||||
|
delete(cr.runIndex, runID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setClientOffline(info *ClientInfo, now time.Time) {
|
||||||
|
info.RunID = ""
|
||||||
|
info.ControlID = 0
|
||||||
|
info.Online = false
|
||||||
|
info.DisconnectedAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns a snapshot of all known clients.
|
// List returns a snapshot of all known clients.
|
||||||
|
|||||||
@@ -72,3 +72,89 @@ func TestClientRegistryUsesClockForTimestamps(t *testing.T) {
|
|||||||
t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
|
t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientRegistryControlIDPreventsStaleOffline(t *testing.T) {
|
||||||
|
registry := NewClientRegistry()
|
||||||
|
key, conflict := registry.RegisterWithControlID(
|
||||||
|
"user", "client-id", "run-id", "old-host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("unexpected client conflict")
|
||||||
|
}
|
||||||
|
_, conflict = registry.RegisterWithControlID(
|
||||||
|
"user", "client-id", "run-id", "new-host", "1.0.1", "127.0.0.2", wire.ProtocolV2, 2,
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("same run ID replacement should not conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.MarkOfflineByRunIDAndControlID("run-id", 1)
|
||||||
|
info, ok := registry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("client %q not found", key)
|
||||||
|
}
|
||||||
|
if !info.Online || info.ControlID != 2 || info.Hostname != "new-host" {
|
||||||
|
t.Fatalf("stale offline changed current generation: %+v", info)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.MarkOfflineByRunIDAndControlID("run-id", 2)
|
||||||
|
info, ok = registry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("client %q not found after disconnect", key)
|
||||||
|
}
|
||||||
|
if info.Online || info.ControlID != 0 || info.RunID != "" {
|
||||||
|
t.Fatalf("current generation was not marked offline: %+v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRegistryClientIDConflictSemantics(t *testing.T) {
|
||||||
|
registry := NewClientRegistry()
|
||||||
|
_, conflict := registry.RegisterWithControlID(
|
||||||
|
"user", "client-id", "run-one", "host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("unexpected initial client conflict")
|
||||||
|
}
|
||||||
|
_, conflict = registry.RegisterWithControlID(
|
||||||
|
"user", "client-id", "run-two", "host", "1.0.0", "127.0.0.2", wire.ProtocolV1, 2,
|
||||||
|
)
|
||||||
|
if !conflict {
|
||||||
|
t.Fatal("different online run IDs with the same explicit client ID must conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.MarkOfflineByRunIDAndControlID("run-one", 1)
|
||||||
|
_, conflict = registry.RegisterWithControlID(
|
||||||
|
"user", "client-id", "run-two", "host", "1.0.0", "127.0.0.2", wire.ProtocolV1, 2,
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("offline explicit client ID should be reusable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRegistrySameRunIDMovesBetweenClientKeys(t *testing.T) {
|
||||||
|
registry := NewClientRegistry()
|
||||||
|
oldKey, conflict := registry.RegisterWithControlID(
|
||||||
|
"user", "old-client", "run-id", "old-host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("unexpected initial client conflict")
|
||||||
|
}
|
||||||
|
newKey, conflict := registry.RegisterWithControlID(
|
||||||
|
"user", "new-client", "run-id", "new-host", "1.0.1", "127.0.0.2", wire.ProtocolV2, 2,
|
||||||
|
)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("same run ID moving to a new client key should not conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInfo, ok := registry.GetByKey(oldKey)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("old explicit client %q should remain as offline history", oldKey)
|
||||||
|
}
|
||||||
|
if oldInfo.Online || oldInfo.RunID != "" || oldInfo.ControlID != 0 {
|
||||||
|
t.Fatalf("old client key remained online: %+v", oldInfo)
|
||||||
|
}
|
||||||
|
newInfo, ok := registry.GetByKey(newKey)
|
||||||
|
if !ok || !newInfo.Online || newInfo.RunID != "run-id" || newInfo.ControlID != 2 {
|
||||||
|
t.Fatalf("new client key was not registered: %+v", newInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+68
-46
@@ -18,6 +18,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -51,7 +52,6 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/server/controller"
|
"github.com/fatedier/frp/server/controller"
|
||||||
"github.com/fatedier/frp/server/group"
|
"github.com/fatedier/frp/server/group"
|
||||||
"github.com/fatedier/frp/server/metrics"
|
|
||||||
"github.com/fatedier/frp/server/ports"
|
"github.com/fatedier/frp/server/ports"
|
||||||
"github.com/fatedier/frp/server/proxy"
|
"github.com/fatedier/frp/server/proxy"
|
||||||
"github.com/fatedier/frp/server/registry"
|
"github.com/fatedier/frp/server/registry"
|
||||||
@@ -64,6 +64,8 @@ const (
|
|||||||
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errControlReplaced = errors.New("control was replaced during login")
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
crypto.DefaultSalt = "frp"
|
crypto.DefaultSalt = "frp"
|
||||||
// Disable quic-go's receive buffer warning.
|
// Disable quic-go's receive buffer warning.
|
||||||
@@ -161,9 +163,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
svr := &Service{
|
svr := &Service{
|
||||||
ctlManager: NewControlManager(),
|
ctlManager: NewControlManager(clientRegistry),
|
||||||
clientRegistry: registry.NewClientRegistry(),
|
clientRegistry: clientRegistry,
|
||||||
pxyManager: proxy.NewManager(),
|
pxyManager: proxy.NewManager(),
|
||||||
pluginManager: plugin.NewManager(),
|
pluginManager: plugin.NewManager(),
|
||||||
rc: &controller.ResourceController{
|
rc: &controller.ResourceController{
|
||||||
@@ -297,10 +300,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
svr.rc.HTTPReverseProxy = rp
|
svr.rc.HTTPReverseProxy = rp
|
||||||
|
|
||||||
address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort))
|
address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort))
|
||||||
|
protocols := new(http.Protocols)
|
||||||
|
protocols.SetHTTP1(true)
|
||||||
|
protocols.SetUnencryptedHTTP2(true)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: address,
|
Addr: address,
|
||||||
Handler: rp,
|
Handler: rp,
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
|
Protocols: protocols,
|
||||||
}
|
}
|
||||||
var l net.Listener
|
var l net.Listener
|
||||||
if httpMuxOn {
|
if httpMuxOn {
|
||||||
@@ -469,6 +476,9 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("register control error: %v", err)
|
xl.Warnf("register control error: %v", err)
|
||||||
|
if ctl != nil {
|
||||||
|
svr.ctlManager.Remove(ctl)
|
||||||
|
}
|
||||||
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
@@ -477,29 +487,27 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
}); writeErr != nil {
|
}); writeErr != nil {
|
||||||
xl.Warnf("write login error response error: %v", writeErr)
|
xl.Warnf("write login error response error: %v", writeErr)
|
||||||
}
|
}
|
||||||
conn.Close()
|
if ctl != nil {
|
||||||
|
_ = ctl.Close()
|
||||||
|
} else {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = writeWithDeadline(conn, connWriteTimeout, func() error {
|
if err = svr.completeControlLogin(ctl, func() error {
|
||||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
return writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
Version: version.Full(),
|
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||||
RunID: ctl.runID,
|
Version: version.Full(),
|
||||||
Error: "",
|
RunID: ctl.runID,
|
||||||
|
Error: "",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
xl.Warnf("write login response error: %v", err)
|
xl.Warnf("complete control login error: %v", err)
|
||||||
svr.ctlManager.Del(m.RunID, ctl)
|
svr.ctlManager.Remove(ctl)
|
||||||
svr.clientRegistry.MarkOfflineByRunID(m.RunID)
|
_ = ctl.Close()
|
||||||
conn.Close()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctl.Start()
|
|
||||||
metrics.Server.NewClient()
|
|
||||||
go func() {
|
|
||||||
// block until control closed
|
|
||||||
ctl.WaitClosed()
|
|
||||||
svr.ctlManager.Del(m.RunID, ctl)
|
|
||||||
}()
|
|
||||||
case *msg.NewWorkConn:
|
case *msg.NewWorkConn:
|
||||||
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
|
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
|
||||||
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
|
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
|
||||||
@@ -527,6 +535,17 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svr *Service) completeControlLogin(ctl *Control, writeSuccess func() error) error {
|
||||||
|
committed, err := svr.ctlManager.completeLogin(ctl, writeSuccess)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !committed {
|
||||||
|
return errControlReplaced
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type acceptedConnection struct {
|
type acceptedConnection struct {
|
||||||
conn *msg.Conn
|
conn *msg.Conn
|
||||||
wireProtocol string
|
wireProtocol string
|
||||||
@@ -768,16 +787,15 @@ func (svr *Service) RegisterControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctl, err := NewControl(ctx, &SessionContext{
|
ctl, err := NewControl(ctx, &SessionContext{
|
||||||
RC: svr.rc,
|
RC: svr.rc,
|
||||||
PxyManager: svr.pxyManager,
|
PxyManager: svr.pxyManager,
|
||||||
PluginManager: svr.pluginManager,
|
PluginManager: svr.pluginManager,
|
||||||
AuthVerifier: authVerifier,
|
AuthVerifier: authVerifier,
|
||||||
EncryptionKey: svr.auth.EncryptionKey(),
|
EncryptionKey: svr.auth.EncryptionKey(),
|
||||||
Conn: ctlConn,
|
Conn: ctlConn,
|
||||||
LoginMsg: loginMsg,
|
LoginMsg: loginMsg,
|
||||||
ServerCfg: svr.cfg,
|
ServerCfg: svr.cfg,
|
||||||
ClientRegistry: svr.clientRegistry,
|
WireProtocol: wireProtocol,
|
||||||
WireProtocol: wireProtocol,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("create new controller error: %v", err)
|
xl.Warnf("create new controller error: %v", err)
|
||||||
@@ -785,18 +803,17 @@ func (svr *Service) RegisterControl(
|
|||||||
return nil, fmt.Errorf("unexpected error when creating new controller")
|
return nil, fmt.Errorf("unexpected error when creating new controller")
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
|
if err := svr.ctlManager.Add(ctl); err != nil {
|
||||||
oldCtl.WaitClosed()
|
return ctl, err
|
||||||
}
|
}
|
||||||
|
ctl.WaitForHandoff()
|
||||||
|
|
||||||
remoteAddr := ctlConn.RemoteAddr().String()
|
active, err := svr.ctlManager.Activate(ctl)
|
||||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
if err != nil {
|
||||||
remoteAddr = host
|
return ctl, err
|
||||||
}
|
}
|
||||||
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr, wireProtocol)
|
if !active {
|
||||||
if conflict {
|
return ctl, errControlReplaced
|
||||||
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
|
||||||
return nil, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctl, nil
|
return ctl, nil
|
||||||
@@ -830,20 +847,25 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
|
|||||||
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
|
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
return svr.ctlManager.RegisterWorkConn(ctl, proxy.NewWorkConn(workConn))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
||||||
visitorUser := ""
|
admit := func(visitorUser string) error {
|
||||||
|
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||||
|
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
||||||
|
}
|
||||||
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
|
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
|
||||||
// If runID is required, it is not compatible with versions prior to v0.50.0.
|
// If runID is required, it is not compatible with versions prior to v0.50.0.
|
||||||
if newMsg.RunID != "" {
|
if newMsg.RunID != "" {
|
||||||
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
|
admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, admit)
|
||||||
if !exist {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !admitted {
|
||||||
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
|
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
|
||||||
}
|
}
|
||||||
visitorUser = ctl.sessionCtx.LoginMsg.User
|
return nil
|
||||||
}
|
}
|
||||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
return admit("")
|
||||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,29 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatedier/golib/net/mux"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/auth"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
|
"github.com/fatedier/frp/server/controller"
|
||||||
|
"github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
|
"github.com/fatedier/frp/server/visitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
||||||
@@ -61,3 +78,569 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
|||||||
t.Fatal("timed out waiting for write after deadline reset")
|
t.Fatal("timed out waiting for write after deadline reset")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedPortHTTPListenerProtocols(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sharedMux := mux.NewMux(listener)
|
||||||
|
httpListener := sharedMux.ListenHTTP(1)
|
||||||
|
muxServeErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
muxServeErr <- sharedMux.Serve()
|
||||||
|
}()
|
||||||
|
|
||||||
|
newProtocols := func(http1, unencryptedHTTP2 bool) *http.Protocols {
|
||||||
|
protocols := new(http.Protocols)
|
||||||
|
protocols.SetHTTP1(http1)
|
||||||
|
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
|
||||||
|
return protocols
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlerProtocolHeader = "X-Test-Handler-Protocol"
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set(handlerProtocolHeader, r.Proto)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}),
|
||||||
|
ReadHeaderTimeout: time.Second,
|
||||||
|
Protocols: newProtocols(true, true),
|
||||||
|
}
|
||||||
|
httpServeErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
httpServeErr <- httpServer.Serve(httpListener)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, httpServer.Close())
|
||||||
|
require.ErrorIs(t, waitForResult(t, httpServeErr, "shared HTTP server to stop"), http.ErrServerClosed)
|
||||||
|
require.NoError(t, sharedMux.Close())
|
||||||
|
require.ErrorIs(t, waitForResult(t, muxServeErr, "shared mux to stop"), net.ErrClosed)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
http1 bool
|
||||||
|
unencryptedHTTP2 bool
|
||||||
|
expectedProtocol string
|
||||||
|
}{
|
||||||
|
{name: "HTTP/1.1", http1: true, expectedProtocol: "HTTP/1.1"},
|
||||||
|
{name: "HTTP/2 prior knowledge", unencryptedHTTP2: true, expectedProtocol: "HTTP/2.0"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
transport := &http.Transport{
|
||||||
|
Protocols: newProtocols(tc.http1, tc.unencryptedHTTP2),
|
||||||
|
}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||||
|
request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+listener.Addr().String()+"/", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
response, err := client.Do(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusNoContent, response.StatusCode)
|
||||||
|
require.Equal(t, tc.expectedProtocol, response.Proto)
|
||||||
|
require.Equal(t, tc.expectedProtocol, response.Header.Get(handlerProtocolHeader))
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceControlHandoffSkipsStalePendingGeneration(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
metrics.closeEnter = make(chan struct{})
|
||||||
|
metrics.closeResume = make(chan struct{})
|
||||||
|
|
||||||
|
ctlA, connA, err := registerLifecycleTestControl(svr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctlA.serverMetrics = metrics
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctlA, func() error { return nil }))
|
||||||
|
waitForSignal(t, connA.readStarted, "A reader to start")
|
||||||
|
|
||||||
|
require.NoError(t, ctlA.Close())
|
||||||
|
waitForSignal(t, metrics.closeEnter, "A finalization barrier")
|
||||||
|
|
||||||
|
type registerResult struct {
|
||||||
|
ctl *Control
|
||||||
|
conn *deadlineReadConn
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultB := make(chan registerResult, 1)
|
||||||
|
go func() {
|
||||||
|
ctl, conn, registerErr := registerLifecycleTestControl(svr)
|
||||||
|
resultB <- registerResult{ctl: ctl, conn: conn, err: registerErr}
|
||||||
|
}()
|
||||||
|
ctlB := waitForDifferentCurrentControl(t, svr.ctlManager, "shared-run", ctlA)
|
||||||
|
ctlB.serverMetrics = metrics
|
||||||
|
|
||||||
|
resultC := make(chan registerResult, 1)
|
||||||
|
go func() {
|
||||||
|
ctl, conn, registerErr := registerLifecycleTestControl(svr)
|
||||||
|
resultC <- registerResult{ctl: ctl, conn: conn, err: registerErr}
|
||||||
|
}()
|
||||||
|
ctlC := waitForDifferentCurrentControl(t, svr.ctlManager, "shared-run", ctlB)
|
||||||
|
ctlC.serverMetrics = metrics
|
||||||
|
waitForControlDone(t, ctlB)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-resultB:
|
||||||
|
t.Fatalf("B returned before A finalized: %v", result.err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case result := <-resultC:
|
||||||
|
t.Fatalf("C returned before A finalized: %v", result.err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
close(metrics.closeResume)
|
||||||
|
waitForControlDone(t, ctlA)
|
||||||
|
|
||||||
|
b := <-resultB
|
||||||
|
require.Same(t, ctlB, b.ctl)
|
||||||
|
require.ErrorIs(t, b.err, errControlReplaced)
|
||||||
|
require.False(t, svr.ctlManager.Remove(ctlB))
|
||||||
|
require.NoError(t, ctlB.Close())
|
||||||
|
|
||||||
|
c := <-resultC
|
||||||
|
require.NoError(t, c.err)
|
||||||
|
require.Same(t, ctlC, c.ctl)
|
||||||
|
_, ok := svr.ctlManager.GetByID("shared-run")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Same(t, ctlC, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||||
|
|
||||||
|
info, ok := svr.clientRegistry.GetByKey("client")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, info.Online)
|
||||||
|
require.Equal(t, uint64(ctlC.ID()), info.ControlID)
|
||||||
|
|
||||||
|
var staleWrites atomic.Int64
|
||||||
|
err = svr.completeControlLogin(ctlB, func() error {
|
||||||
|
staleWrites.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, errControlReplaced)
|
||||||
|
require.Equal(t, int64(0), staleWrites.Load())
|
||||||
|
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctlC, func() error { return nil }))
|
||||||
|
waitForSignal(t, c.conn.readStarted, "C reader to start")
|
||||||
|
current, ok := svr.ctlManager.GetByID("shared-run")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Same(t, ctlC, current)
|
||||||
|
require.Equal(t, int64(2), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
|
||||||
|
require.NoError(t, ctlC.Close())
|
||||||
|
waitForControlDone(t, ctlC)
|
||||||
|
require.Equal(t, int64(2), metrics.newClients())
|
||||||
|
require.Equal(t, int64(2), metrics.closedClients())
|
||||||
|
_, ok = svr.ctlManager.GetByID("shared-run")
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLoginResponseSynchronizationIsScopedToRun(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
metrics := newCountingServerMetrics()
|
||||||
|
ctlA, connA, err := registerLifecycleTestControl(svr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctlA.serverMetrics = metrics
|
||||||
|
|
||||||
|
writeEntered := make(chan struct{})
|
||||||
|
resumeWrite := make(chan struct{})
|
||||||
|
var resumeWriteOnce sync.Once
|
||||||
|
resume := func() {
|
||||||
|
resumeWriteOnce.Do(func() { close(resumeWrite) })
|
||||||
|
}
|
||||||
|
t.Cleanup(resume)
|
||||||
|
writeCount := atomic.Int64{}
|
||||||
|
loginDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
loginDone <- svr.completeControlLogin(ctlA, func() error {
|
||||||
|
close(writeEntered)
|
||||||
|
<-resumeWrite
|
||||||
|
writeCount.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
waitForSignal(t, writeEntered, "A LoginResp write")
|
||||||
|
|
||||||
|
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
|
||||||
|
require.NotNil(t, runMu)
|
||||||
|
if !svr.ctlManager.mu.TryLock() {
|
||||||
|
t.Fatal("ControlManager mutex was held while LoginResp write was in progress")
|
||||||
|
}
|
||||||
|
svr.ctlManager.mu.Unlock()
|
||||||
|
|
||||||
|
ctlB, connB := newLifecycleTestControl(t, "shared-run", "client", metrics)
|
||||||
|
gateAvailable := make(chan bool)
|
||||||
|
addDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if runMu.TryLock() {
|
||||||
|
runMu.Unlock()
|
||||||
|
gateAvailable <- true
|
||||||
|
} else {
|
||||||
|
gateAvailable <- false
|
||||||
|
}
|
||||||
|
addErr := svr.ctlManager.Add(ctlB)
|
||||||
|
addDone <- addErr
|
||||||
|
}()
|
||||||
|
available := waitForResult(t, gateAvailable, "same-run replacement gate probe")
|
||||||
|
require.False(t, available, "same-run gate was available to replacement during LoginResp write")
|
||||||
|
select {
|
||||||
|
case addErr := <-addDone:
|
||||||
|
t.Fatalf("same-run replacement completed during LoginResp write: %v", addErr)
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
require.Same(t, ctlA, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||||
|
|
||||||
|
otherMetrics := newCountingServerMetrics()
|
||||||
|
otherCtl, otherConn := newLifecycleTestControl(t, "other-run", "other-client", otherMetrics)
|
||||||
|
type unrelatedResult struct {
|
||||||
|
addErr error
|
||||||
|
active bool
|
||||||
|
activateErr error
|
||||||
|
loginErr error
|
||||||
|
current *Control
|
||||||
|
found bool
|
||||||
|
}
|
||||||
|
unrelatedDone := make(chan unrelatedResult, 1)
|
||||||
|
go func() {
|
||||||
|
result := unrelatedResult{}
|
||||||
|
result.addErr = svr.ctlManager.Add(otherCtl)
|
||||||
|
if result.addErr == nil {
|
||||||
|
result.active, result.activateErr = svr.ctlManager.Activate(otherCtl)
|
||||||
|
}
|
||||||
|
if result.activateErr == nil && result.active {
|
||||||
|
result.loginErr = svr.completeControlLogin(otherCtl, func() error { return nil })
|
||||||
|
}
|
||||||
|
result.current, result.found = svr.ctlManager.GetByID("other-run")
|
||||||
|
unrelatedDone <- result
|
||||||
|
}()
|
||||||
|
result := waitForResult(t, unrelatedDone, "unrelated run lifecycle")
|
||||||
|
require.NoError(t, result.addErr)
|
||||||
|
require.NoError(t, result.activateErr)
|
||||||
|
require.True(t, result.active)
|
||||||
|
require.NoError(t, result.loginErr)
|
||||||
|
require.True(t, result.found)
|
||||||
|
require.Same(t, otherCtl, result.current)
|
||||||
|
waitForSignal(t, otherConn.readStarted, "unrelated control reader to start")
|
||||||
|
require.Equal(t, int64(1), otherMetrics.newClients())
|
||||||
|
|
||||||
|
resume()
|
||||||
|
require.NoError(t, waitForResult(t, loginDone, "LoginResp completion"))
|
||||||
|
require.NoError(t, waitForResult(t, addDone, "replacement"))
|
||||||
|
waitForControlDone(t, ctlA)
|
||||||
|
require.Same(t, ctlB, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||||
|
require.Equal(t, int64(1), writeCount.Load())
|
||||||
|
require.Equal(t, int64(1), metrics.newClients())
|
||||||
|
require.Equal(t, int64(1), metrics.closedClients())
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, connA.eventsSnapshot())
|
||||||
|
|
||||||
|
require.False(t, svr.ctlManager.Remove(ctlA))
|
||||||
|
require.NoError(t, ctlA.Close())
|
||||||
|
require.True(t, svr.ctlManager.Remove(ctlB))
|
||||||
|
require.NoError(t, ctlB.Close())
|
||||||
|
require.Equal(t, []string{"deadline", "close"}, connB.eventsSnapshot())
|
||||||
|
|
||||||
|
require.NoError(t, otherCtl.Close())
|
||||||
|
waitForControlDone(t, otherCtl)
|
||||||
|
require.Equal(t, int64(1), otherMetrics.newClients())
|
||||||
|
require.Equal(t, int64(1), otherMetrics.closedClients())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
ctlA, controlConn, err := registerLifecycleTestControl(svr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctlA.sessionCtx.LoginMsg.User = "old-user"
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctlA, func() error { return nil }))
|
||||||
|
waitForSignal(t, controlConn.readStarted, "A reader to start")
|
||||||
|
|
||||||
|
admissionEntered := make(chan struct{})
|
||||||
|
resumeAdmission := make(chan struct{})
|
||||||
|
var resumeOnce sync.Once
|
||||||
|
resume := func() {
|
||||||
|
resumeOnce.Do(func() { close(resumeAdmission) })
|
||||||
|
}
|
||||||
|
t.Cleanup(resume)
|
||||||
|
type admissionResult struct {
|
||||||
|
admitted bool
|
||||||
|
user string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
admissionDone := make(chan admissionResult, 1)
|
||||||
|
go func() {
|
||||||
|
var admittedUser string
|
||||||
|
admitted, admitErr := svr.ctlManager.admitVisitorByRunID("shared-run", func(user string) error {
|
||||||
|
admittedUser = user
|
||||||
|
close(admissionEntered)
|
||||||
|
<-resumeAdmission
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
admissionDone <- admissionResult{admitted: admitted, user: admittedUser, err: admitErr}
|
||||||
|
}()
|
||||||
|
waitForSignal(t, admissionEntered, "visitor admission callback")
|
||||||
|
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
|
||||||
|
require.NotNil(t, runMu)
|
||||||
|
|
||||||
|
type registerResult struct {
|
||||||
|
ctl *Control
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
gateAvailable := make(chan bool)
|
||||||
|
replacementDone := make(chan registerResult, 1)
|
||||||
|
go func() {
|
||||||
|
if runMu.TryLock() {
|
||||||
|
runMu.Unlock()
|
||||||
|
gateAvailable <- true
|
||||||
|
} else {
|
||||||
|
gateAvailable <- false
|
||||||
|
}
|
||||||
|
ctl, _, registerErr := registerLifecycleTestControl(svr)
|
||||||
|
replacementDone <- registerResult{ctl: ctl, err: registerErr}
|
||||||
|
}()
|
||||||
|
available := waitForResult(t, gateAvailable, "visitor replacement gate probe")
|
||||||
|
require.False(t, available, "same-run gate was available during visitor admission")
|
||||||
|
select {
|
||||||
|
case result := <-replacementDone:
|
||||||
|
t.Fatalf("replacement completed during visitor admission: %v", result.err)
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
require.Same(t, ctlA, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||||
|
|
||||||
|
resume()
|
||||||
|
admission := waitForResult(t, admissionDone, "visitor admission")
|
||||||
|
require.NoError(t, admission.err)
|
||||||
|
require.True(t, admission.admitted)
|
||||||
|
require.Equal(t, "old-user", admission.user)
|
||||||
|
replacement := waitForResult(t, replacementDone, "replacement")
|
||||||
|
require.NoError(t, replacement.err)
|
||||||
|
ctlB := replacement.ctl
|
||||||
|
require.Same(t, ctlB, currentControlForTest(svr.ctlManager, "shared-run"))
|
||||||
|
waitForControlDone(t, ctlA)
|
||||||
|
require.True(t, svr.ctlManager.Remove(ctlB))
|
||||||
|
require.NoError(t, ctlB.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
ctl, controlConn, err := registerLifecycleTestControl(svr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pendingConn := newCountingCloseConn()
|
||||||
|
pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn))
|
||||||
|
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, int64(1), pendingConn.closeCount.Load())
|
||||||
|
require.Len(t, ctl.workConnCh, 0)
|
||||||
|
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||||
|
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
||||||
|
current, ok := svr.ctlManager.GetByID("shared-run")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Same(t, ctl, current)
|
||||||
|
require.Len(t, ctl.workConnCh, 0)
|
||||||
|
|
||||||
|
runningConn := newCountingCloseConn()
|
||||||
|
runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn))
|
||||||
|
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}))
|
||||||
|
require.Len(t, ctl.workConnCh, 1)
|
||||||
|
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
require.Equal(t, int64(1), runningConn.closeCount.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
|
||||||
|
for _, action := range []string{"replace", "close"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
ctl, controlConn, err := registerLifecycleTestControl(svr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||||
|
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
||||||
|
|
||||||
|
barrier := newWorkConnBarrierPlugin()
|
||||||
|
svr.pluginManager.Register(barrier)
|
||||||
|
workConn := newCountingCloseConn()
|
||||||
|
workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn))
|
||||||
|
routeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
|
||||||
|
}()
|
||||||
|
waitForSignal(t, barrier.entered, "work connection plugin barrier")
|
||||||
|
|
||||||
|
var replacement *Control
|
||||||
|
switch action {
|
||||||
|
case "replace":
|
||||||
|
replacement, _, err = registerLifecycleTestControl(svr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
case "close":
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(barrier.resume)
|
||||||
|
require.Error(t, waitForResult(t, routeDone, "work connection route to finish"))
|
||||||
|
require.Equal(t, int64(1), workConn.closeCount.Load())
|
||||||
|
require.Len(t, ctl.workConnCh, 0)
|
||||||
|
|
||||||
|
if replacement != nil {
|
||||||
|
require.Len(t, replacement.workConnCh, 0)
|
||||||
|
require.True(t, svr.ctlManager.Remove(replacement))
|
||||||
|
require.NoError(t, replacement.Close())
|
||||||
|
waitForControlDone(t, replacement)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{"pending-user"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
|
||||||
|
controlConn := newDeadlineReadConn()
|
||||||
|
controlMsgConn := msg.NewConn(controlConn, msg.NewV1ReadWriter(controlConn))
|
||||||
|
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
|
||||||
|
RunID: "visitor-run",
|
||||||
|
User: "pending-user",
|
||||||
|
ClientID: "visitor-client",
|
||||||
|
ClientSpec: msg.ClientSpec{
|
||||||
|
AlwaysAuthPass: true,
|
||||||
|
},
|
||||||
|
}, true, wire.ProtocolV1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
visitorMsg := &msg.NewVisitorConn{
|
||||||
|
RunID: "visitor-run",
|
||||||
|
ProxyName: "visitor",
|
||||||
|
Timestamp: timestamp,
|
||||||
|
SignKey: util.GetAuthKey("secret", timestamp),
|
||||||
|
}
|
||||||
|
pendingConn := newCountingCloseConn()
|
||||||
|
err = svr.RegisterVisitorConn(pendingConn, visitorMsg, wire.ProtocolV1)
|
||||||
|
require.ErrorContains(t, err, "no client control found")
|
||||||
|
require.NoError(t, pendingConn.Close())
|
||||||
|
require.Equal(t, int64(1), pendingConn.closeCount.Load())
|
||||||
|
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||||
|
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
||||||
|
runningConn := newCountingCloseConn()
|
||||||
|
require.NoError(t, svr.RegisterVisitorConn(runningConn, visitorMsg, wire.ProtocolV1))
|
||||||
|
accepted, err := listener.Accept()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, accepted.Close())
|
||||||
|
require.Equal(t, int64(1), runningConn.closeCount.Load())
|
||||||
|
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newControlTestService(t *testing.T) *Service {
|
||||||
|
t.Helper()
|
||||||
|
cfg := &v1.ServerConfig{}
|
||||||
|
cfg.Auth.Method = v1.AuthMethodToken
|
||||||
|
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
return &Service{
|
||||||
|
ctlManager: NewControlManager(clientRegistry),
|
||||||
|
clientRegistry: clientRegistry,
|
||||||
|
pxyManager: proxy.NewManager(),
|
||||||
|
pluginManager: plugin.NewManager(),
|
||||||
|
rc: &controller.ResourceController{
|
||||||
|
VisitorManager: visitor.NewManager(),
|
||||||
|
},
|
||||||
|
auth: authRuntime,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, error) {
|
||||||
|
conn := newDeadlineReadConn()
|
||||||
|
msgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wire.ProtocolV1))
|
||||||
|
ctl, err := svr.RegisterControl(msgConn, &msg.Login{
|
||||||
|
RunID: "shared-run",
|
||||||
|
ClientID: "client",
|
||||||
|
ClientSpec: msg.ClientSpec{
|
||||||
|
AlwaysAuthPass: true,
|
||||||
|
},
|
||||||
|
}, true, wire.ProtocolV1)
|
||||||
|
return ctl, conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID string, old *Control) *Control {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if ctl := currentControlForTest(manager, runID); ctl != nil && ctl != old {
|
||||||
|
return ctl
|
||||||
|
}
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
t.Fatalf("timed out waiting for a new current control after ID %d", old.ID())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWorkConnAsCaller(svr *Service, workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
|
||||||
|
err := svr.RegisterWorkConn(workConn, newMsg)
|
||||||
|
if err != nil {
|
||||||
|
_ = workConn.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForResult[T any](t *testing.T, ch <-chan T, description string) T {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case result := <-ch:
|
||||||
|
return result
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for %s", description)
|
||||||
|
var zero T
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type workConnBarrierPlugin struct {
|
||||||
|
entered chan struct{}
|
||||||
|
resume chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWorkConnBarrierPlugin() *workConnBarrierPlugin {
|
||||||
|
return &workConnBarrierPlugin{
|
||||||
|
entered: make(chan struct{}),
|
||||||
|
resume: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*workConnBarrierPlugin) Name() string { return "work-conn-barrier" }
|
||||||
|
|
||||||
|
func (*workConnBarrierPlugin) IsSupport(op string) bool { return op == plugin.OpNewWorkConn }
|
||||||
|
|
||||||
|
func (p *workConnBarrierPlugin) Handle(
|
||||||
|
context.Context,
|
||||||
|
string,
|
||||||
|
any,
|
||||||
|
) (*plugin.Response, any, error) {
|
||||||
|
close(p.entered)
|
||||||
|
<-p.resume
|
||||||
|
return &plugin.Response{Unchange: true}, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingCloseConn struct {
|
||||||
|
closeCount atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCountingCloseConn() *countingCloseConn { return &countingCloseConn{} }
|
||||||
|
|
||||||
|
func (*countingCloseConn) Read([]byte) (int, error) { return 0, net.ErrClosed }
|
||||||
|
func (*countingCloseConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
func (c *countingCloseConn) Close() error { c.closeCount.Add(1); return nil }
|
||||||
|
func (*countingCloseConn) LocalAddr() net.Addr { return lifecycleTestAddr("local") }
|
||||||
|
func (*countingCloseConn) RemoteAddr() net.Addr { return lifecycleTestAddr("remote") }
|
||||||
|
func (*countingCloseConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (*countingCloseConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (*countingCloseConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|||||||
@@ -94,11 +94,9 @@ func (f *Framework) RunProcessesWithBinaries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
||||||
p := process.NewWithEnvs(TestContext.FRPServerPath, args, f.osEnvs)
|
p, output, err := f.StartFrps(args...)
|
||||||
f.serverProcesses = append(f.serverProcesses, p)
|
|
||||||
err := p.Start()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, p.Output(), err
|
return p, output, err
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-p.Done():
|
case <-p.Done():
|
||||||
@@ -107,17 +105,39 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
|||||||
return p, p.Output(), nil
|
return p, p.Output(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartFrps starts frps without an implicit sleep so tests can wait on an
|
||||||
|
// explicit readiness event.
|
||||||
|
func (f *Framework) StartFrps(args ...string) (*process.Process, string, error) {
|
||||||
|
p := process.NewWithEnvs(TestContext.FRPServerPath, args, f.osEnvs)
|
||||||
|
f.serverProcesses = append(f.serverProcesses, p)
|
||||||
|
err := p.Start()
|
||||||
|
if err != nil {
|
||||||
|
return p, p.Output(), err
|
||||||
|
}
|
||||||
|
return p, p.Output(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
|
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
|
||||||
|
p, output, err := f.StartFrpc(args...)
|
||||||
|
if err != nil {
|
||||||
|
return p, output, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-p.Done():
|
||||||
|
case <-time.After(1500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
return p, p.Output(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartFrpc starts frpc without an implicit sleep so tests can wait on an
|
||||||
|
// explicit login or proxy-readiness event.
|
||||||
|
func (f *Framework) StartFrpc(args ...string) (*process.Process, string, error) {
|
||||||
p := process.NewWithEnvs(TestContext.FRPClientPath, args, f.osEnvs)
|
p := process.NewWithEnvs(TestContext.FRPClientPath, args, f.osEnvs)
|
||||||
f.clientProcesses = append(f.clientProcesses, p)
|
f.clientProcesses = append(f.clientProcesses, p)
|
||||||
err := p.Start()
|
err := p.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, p.Output(), err
|
return p, p.Output(), err
|
||||||
}
|
}
|
||||||
select {
|
|
||||||
case <-p.Done():
|
|
||||||
case <-time.After(1500 * time.Millisecond):
|
|
||||||
}
|
|
||||||
return p, p.Output(), nil
|
return p, p.Output(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// 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 relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HalfOpen forwards TCP connections until Blackhole is called. A blackholed
|
||||||
|
// pair stops forwarding but deliberately retains the upstream socket so the
|
||||||
|
// peer sees a real half-open connection until the relay is closed.
|
||||||
|
type HalfOpen struct {
|
||||||
|
bindAddr string
|
||||||
|
bindPort int
|
||||||
|
upstreamAddr string
|
||||||
|
|
||||||
|
listener net.Listener
|
||||||
|
done chan struct{}
|
||||||
|
accepted chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pairs []*connectionPair
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
type connectionPair struct {
|
||||||
|
downstream net.Conn
|
||||||
|
upstream net.Conn
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
blackholed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(upstreamAddr string) *HalfOpen {
|
||||||
|
return &HalfOpen{
|
||||||
|
bindAddr: "127.0.0.1",
|
||||||
|
upstreamAddr: upstreamAddr,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
accepted: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HalfOpen) Run() error {
|
||||||
|
listener, err := net.Listen("tcp", net.JoinHostPort(r.bindAddr, strconv.Itoa(r.bindPort)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.listener = listener
|
||||||
|
r.bindPort = listener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
r.wg.Add(1)
|
||||||
|
go r.acceptLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HalfOpen) acceptLoop() {
|
||||||
|
defer r.wg.Done()
|
||||||
|
for {
|
||||||
|
downstream, err := r.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upstream, err := net.DialTimeout("tcp", r.upstreamAddr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
_ = downstream.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pair := &connectionPair{downstream: downstream, upstream: upstream}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.pairs = append(r.pairs, pair)
|
||||||
|
r.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case r.accepted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
r.wg.Add(1)
|
||||||
|
go r.servePair(pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HalfOpen) servePair(pair *connectionPair) {
|
||||||
|
defer r.wg.Done()
|
||||||
|
copyDone := make(chan struct{}, 2)
|
||||||
|
go func() {
|
||||||
|
_, _ = io.Copy(pair.upstream, pair.downstream)
|
||||||
|
copyDone <- struct{}{}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, _ = io.Copy(pair.downstream, pair.upstream)
|
||||||
|
copyDone <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
completed := 0
|
||||||
|
select {
|
||||||
|
case <-copyDone:
|
||||||
|
completed = 1
|
||||||
|
if !pair.isBlackholed() {
|
||||||
|
_ = pair.downstream.Close()
|
||||||
|
_ = pair.upstream.Close()
|
||||||
|
}
|
||||||
|
case <-r.done:
|
||||||
|
_ = pair.downstream.Close()
|
||||||
|
_ = pair.upstream.Close()
|
||||||
|
}
|
||||||
|
for completed < 2 {
|
||||||
|
<-copyDone
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair.isBlackholed() {
|
||||||
|
<-r.done
|
||||||
|
_ = pair.downstream.Close()
|
||||||
|
_ = pair.upstream.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HalfOpen) WaitForConnections(count int, timeout time.Duration) error {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
r.mu.Lock()
|
||||||
|
accepted := len(r.pairs)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if accepted >= count {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-r.accepted:
|
||||||
|
case <-r.done:
|
||||||
|
return fmt.Errorf("relay closed after accepting %d of %d connections", accepted, count)
|
||||||
|
case <-timer.C:
|
||||||
|
return fmt.Errorf("timed out after accepting %d of %d connections", accepted, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blackhole uses a one-based connection index in accept order.
|
||||||
|
func (r *HalfOpen) Blackhole(index int) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
if index <= 0 || index > len(r.pairs) {
|
||||||
|
accepted := len(r.pairs)
|
||||||
|
r.mu.Unlock()
|
||||||
|
return fmt.Errorf("connection %d is unavailable; accepted %d", index, accepted)
|
||||||
|
}
|
||||||
|
pair := r.pairs[index-1]
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
pair.mu.Lock()
|
||||||
|
if pair.blackholed {
|
||||||
|
pair.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pair.blackholed = true
|
||||||
|
pair.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_ = pair.downstream.SetDeadline(now)
|
||||||
|
_ = pair.upstream.SetDeadline(now)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HalfOpen) Close() error {
|
||||||
|
r.closeOnce.Do(func() {
|
||||||
|
close(r.done)
|
||||||
|
if r.listener != nil {
|
||||||
|
_ = r.listener.Close()
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
pairs := append([]*connectionPair(nil), r.pairs...)
|
||||||
|
r.mu.Unlock()
|
||||||
|
for _, pair := range pairs {
|
||||||
|
_ = pair.downstream.Close()
|
||||||
|
_ = pair.upstream.Close()
|
||||||
|
}
|
||||||
|
r.wg.Wait()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HalfOpen) BindAddr() string { return r.bindAddr }
|
||||||
|
func (r *HalfOpen) BindPort() int { return r.bindPort }
|
||||||
|
|
||||||
|
func (p *connectionPair) isBlackholed() bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.blackholed
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// 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 features
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/test/e2e/framework"
|
||||||
|
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||||
|
"github.com/fatedier/frp/test/e2e/pkg/relay"
|
||||||
|
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = ginkgo.Describe("[Feature: ControlReplacement]", func() {
|
||||||
|
f := framework.NewDefaultFramework()
|
||||||
|
|
||||||
|
for _, wireProtocol := range []string{"v1", "v2"} {
|
||||||
|
for _, tcpMux := range []bool{true, false} {
|
||||||
|
ginkgo.It(fmt.Sprintf("recovers a %s control through a half-open relay with tcpMux=%t", wireProtocol, tcpMux), func() {
|
||||||
|
runHalfOpenControlReplacement(f, wireProtocol, tcpMux)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
func runHalfOpenControlReplacement(f *framework.Framework, wireProtocol string, tcpMux bool) {
|
||||||
|
serverPort := f.AllocPort()
|
||||||
|
dashboardPort := f.AllocPort()
|
||||||
|
remotePort := f.AllocPort()
|
||||||
|
heartbeatTimeout := int64(-1)
|
||||||
|
if !tcpMux {
|
||||||
|
heartbeatTimeout = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
serverConfig := fmt.Sprintf(`
|
||||||
|
bindAddr = "127.0.0.1"
|
||||||
|
bindPort = %d
|
||||||
|
log.level = "trace"
|
||||||
|
transport.tcpMux = %t
|
||||||
|
transport.tcpMuxKeepaliveInterval = 30
|
||||||
|
transport.heartbeatTimeout = %d
|
||||||
|
webServer.addr = "127.0.0.1"
|
||||||
|
webServer.port = %d
|
||||||
|
webServer.pprofEnable = true
|
||||||
|
enablePrometheus = true
|
||||||
|
`, serverPort, tcpMux, heartbeatTimeout, dashboardPort)
|
||||||
|
serverConfigPath := f.WriteTempFile("issue-5391-frps.toml", serverConfig)
|
||||||
|
serverProcess, _, err := f.StartFrps("-c", serverConfigPath)
|
||||||
|
framework.ExpectNoError(err)
|
||||||
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", serverPort), 5*time.Second))
|
||||||
|
|
||||||
|
halfOpenRelay := relay.New(fmt.Sprintf("127.0.0.1:%d", serverPort))
|
||||||
|
f.RunServer("", halfOpenRelay)
|
||||||
|
|
||||||
|
heartbeatInterval := int64(-1)
|
||||||
|
clientHeartbeatTimeout := int64(-1)
|
||||||
|
if !tcpMux {
|
||||||
|
heartbeatInterval = 1
|
||||||
|
clientHeartbeatTimeout = 3
|
||||||
|
}
|
||||||
|
clientConfig := fmt.Sprintf(`
|
||||||
|
serverAddr = "127.0.0.1"
|
||||||
|
serverPort = %d
|
||||||
|
clientID = "issue-5391"
|
||||||
|
loginFailExit = false
|
||||||
|
log.level = "trace"
|
||||||
|
transport.wireProtocol = %q
|
||||||
|
transport.tcpMux = %t
|
||||||
|
transport.tcpMuxKeepaliveInterval = 1
|
||||||
|
transport.heartbeatInterval = %d
|
||||||
|
transport.heartbeatTimeout = %d
|
||||||
|
transport.tls.enable = false
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "issue-5391-tcp"
|
||||||
|
type = "tcp"
|
||||||
|
localPort = %d
|
||||||
|
remotePort = %d
|
||||||
|
`, halfOpenRelay.BindPort(), wireProtocol, tcpMux, heartbeatInterval, clientHeartbeatTimeout,
|
||||||
|
f.PortByName(framework.TCPEchoServerPort), remotePort)
|
||||||
|
clientConfigPath := f.WriteTempFile("issue-5391-frpc.toml", clientConfig)
|
||||||
|
clientProcess, _, err := f.StartFrpc("-c", clientConfigPath)
|
||||||
|
framework.ExpectNoError(err)
|
||||||
|
framework.ExpectNoError(halfOpenRelay.WaitForConnections(1, 5*time.Second))
|
||||||
|
framework.ExpectNoError(clientProcess.WaitForOutput("login to server success", 1, 10*time.Second))
|
||||||
|
framework.ExpectNoError(clientProcess.WaitForOutput("[issue-5391-tcp] start proxy success", 1, 10*time.Second))
|
||||||
|
framework.ExpectNoError(waitForReplacementState(dashboardPort, remotePort, 10*time.Second))
|
||||||
|
|
||||||
|
replacementCount := 1
|
||||||
|
if tcpMux {
|
||||||
|
replacementCount = 3
|
||||||
|
}
|
||||||
|
for i := 0; i < replacementCount; i++ {
|
||||||
|
connectionIndex := 1
|
||||||
|
if tcpMux {
|
||||||
|
connectionIndex = i + 1
|
||||||
|
}
|
||||||
|
framework.ExpectNoError(halfOpenRelay.Blackhole(connectionIndex))
|
||||||
|
if tcpMux {
|
||||||
|
framework.ExpectNoError(halfOpenRelay.WaitForConnections(connectionIndex+1, 15*time.Second))
|
||||||
|
}
|
||||||
|
framework.ExpectNoError(clientProcess.WaitForOutput("login to server success", i+2, 15*time.Second))
|
||||||
|
framework.ExpectNoError(clientProcess.WaitForOutput("[issue-5391-tcp] start proxy success", i+2, 15*time.Second))
|
||||||
|
framework.ExpectNoError(waitForReplacementState(dashboardPort, remotePort, 10*time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = clientProcess.Stop()
|
||||||
|
select {
|
||||||
|
case <-clientProcess.Done():
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
framework.Failf("frpc did not exit")
|
||||||
|
}
|
||||||
|
framework.ExpectNoError(waitForReplacementShutdown(dashboardPort, 10*time.Second))
|
||||||
|
framework.ExpectNoError(halfOpenRelay.Close())
|
||||||
|
framework.ExpectNoError(waitForNoHandoffWaiters(dashboardPort, 5*time.Second))
|
||||||
|
|
||||||
|
_ = serverProcess.Stop()
|
||||||
|
select {
|
||||||
|
case <-serverProcess.Done():
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
framework.Failf("frps did not exit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForReplacementState(dashboardPort, remotePort int, timeout time.Duration) error {
|
||||||
|
return waitForLifecycleCondition(timeout, func() error {
|
||||||
|
metricsBody, err := getLifecycleEndpoint(dashboardPort, "/metrics")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := expectMetricValue(metricsBody, "frp_server_client_counts", "", 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := expectMetricValue(metricsBody, "frp_server_proxy_counts", `type="tcp"`, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clients, err := getOnlineLifecycleClients(dashboardPort)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(clients) != 1 || clients[0].ClientID != "issue-5391" {
|
||||||
|
return fmt.Errorf("expected one online client, got %+v", clients)
|
||||||
|
}
|
||||||
|
profile, err := getLifecycleEndpoint(dashboardPort, "/debug/pprof/goroutine?debug=2")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := expectNoHandoffWaiter(profile, "after replacement"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := request.New().
|
||||||
|
TCP().
|
||||||
|
Port(remotePort).
|
||||||
|
Timeout(time.Second).
|
||||||
|
Body([]byte(consts.TestString)).
|
||||||
|
Do()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(resp.Content) != consts.TestString {
|
||||||
|
return fmt.Errorf("unexpected proxy response %q", resp.Content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForReplacementShutdown(dashboardPort int, timeout time.Duration) error {
|
||||||
|
return waitForLifecycleCondition(timeout, func() error {
|
||||||
|
metricsBody, err := getLifecycleEndpoint(dashboardPort, "/metrics")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := expectMetricValue(metricsBody, "frp_server_client_counts", "", 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := expectMetricValue(metricsBody, "frp_server_proxy_counts", `type="tcp"`, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clients, err := getOnlineLifecycleClients(dashboardPort)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(clients) != 0 {
|
||||||
|
return fmt.Errorf("expected no online clients, got %+v", clients)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForNoHandoffWaiters(dashboardPort int, timeout time.Duration) error {
|
||||||
|
return waitForLifecycleCondition(timeout, func() error {
|
||||||
|
profile, err := getLifecycleEndpoint(dashboardPort, "/debug/pprof/goroutine?debug=2")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return expectNoHandoffWaiter(profile, "after relay shutdown")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type lifecycleClient struct {
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectNoHandoffWaiter(profile, phase string) error {
|
||||||
|
if strings.Contains(profile, "(*Control).WaitForHandoff") {
|
||||||
|
return fmt.Errorf("control handoff waiter remained %s", phase)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOnlineLifecycleClients(dashboardPort int) ([]lifecycleClient, error) {
|
||||||
|
body, err := getLifecycleEndpoint(dashboardPort, "/api/clients?status=online")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var clients []lifecycleClient
|
||||||
|
if err := json.Unmarshal([]byte(body), &clients); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLifecycleEndpoint(port int, path string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: time.Second}
|
||||||
|
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("GET %s returned %s", path, resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectMetricValue(body, name, labels string, want float64) error {
|
||||||
|
prefix := name
|
||||||
|
if labels != "" {
|
||||||
|
prefix += "{" + labels + "}"
|
||||||
|
}
|
||||||
|
for line := range strings.SplitSeq(body, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) != 2 || fields[0] != prefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, err := strconv.ParseFloat(fields[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("metric %s = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("metric %s not found", prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForLifecycleCondition(timeout time.Duration, condition func() error) error {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
ticker := time.NewTicker(25 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
err := condition()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
case <-timer.C:
|
||||||
|
return fmt.Errorf("condition was not met: %w", lastErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
pp "github.com/pires/go-proxyproto"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
"github.com/fatedier/frp/test/e2e/framework"
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||||
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
||||||
|
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||||
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
|||||||
ExpectResp([]byte("test")).
|
ExpectResp([]byte("test")).
|
||||||
Ensure()
|
Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
||||||
|
generator := &cert.SelfSignedCertGenerator{}
|
||||||
|
artifacts, err := generator.Generate("example.com")
|
||||||
|
framework.ExpectNoError(err)
|
||||||
|
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
||||||
|
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
||||||
|
|
||||||
|
serverConf := consts.DefaultServerConfig
|
||||||
|
vhostHTTPSPort := f.AllocPort()
|
||||||
|
serverConf += fmt.Sprintf(`
|
||||||
|
vhostHTTPSPort = %d
|
||||||
|
`, vhostHTTPSPort)
|
||||||
|
|
||||||
|
localPort := f.AllocPort()
|
||||||
|
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||||
|
[[proxies]]
|
||||||
|
name = "tls2raw-proxy-protocol-test"
|
||||||
|
type = "https"
|
||||||
|
customDomains = ["example.com"]
|
||||||
|
transport.proxyProtocolVersion = "v2"
|
||||||
|
[proxies.plugin]
|
||||||
|
type = "tls2raw"
|
||||||
|
localAddr = "127.0.0.1:%d"
|
||||||
|
crtPath = "%s"
|
||||||
|
keyPath = "%s"
|
||||||
|
`, localPort, crtPath, keyPath)
|
||||||
|
|
||||||
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
|
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||||
|
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
writeResp := func(body string) {
|
||||||
|
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rd := bufio.NewReader(c)
|
||||||
|
ppHeader, err := pp.Read(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read proxy protocol error: %v", err)
|
||||||
|
writeResp("missing proxy protocol")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ppHeader.Version != 2 {
|
||||||
|
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
||||||
|
writeResp("unexpected proxy protocol version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
||||||
|
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
||||||
|
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
||||||
|
writeResp("unexpected proxy protocol source address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.ReadRequest(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read http request after proxy protocol error: %v", err)
|
||||||
|
writeResp("missing http request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, req.Body)
|
||||||
|
_ = req.Body.Close()
|
||||||
|
|
||||||
|
writeResp("test")
|
||||||
|
}))
|
||||||
|
f.RunServer("", localServer)
|
||||||
|
|
||||||
|
framework.NewRequestExpect(f).
|
||||||
|
Port(vhostHTTPSPort).
|
||||||
|
RequestModify(func(r *request.Request) {
|
||||||
|
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
||||||
|
ServerName: "example.com",
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
}).
|
||||||
|
ExpectResp([]byte("test")).
|
||||||
|
Ensure()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+1
@@ -3,6 +3,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
// Generated by unplugin-auto-import
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
|
|
||||||
|
|||||||
Vendored
+7
-2
@@ -1,10 +1,14 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* prettier-ignore */
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
// oxlint-disable
|
||||||
|
// ------
|
||||||
// Generated by unplugin-vue-components
|
// Generated by unplugin-vue-components
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ConfigField: typeof import('./src/components/ConfigField.vue')['default']
|
ConfigField: typeof import('./src/components/ConfigField.vue')['default']
|
||||||
@@ -38,10 +42,11 @@ declare module 'vue' {
|
|||||||
VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
|
VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
|
||||||
VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
|
VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
|
||||||
VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
|
VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
|
||||||
|
VisitorPluginSection: typeof import('./src/components/visitor-form/VisitorPluginSection.vue')['default']
|
||||||
VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
|
VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
|
||||||
VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
|
VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
|
||||||
}
|
}
|
||||||
export interface ComponentCustomProperties {
|
export interface GlobalDirectives {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-13
@@ -9,13 +9,14 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"lint": "eslint --fix"
|
"lint": "eslint . --fix",
|
||||||
|
"lint:check": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"element-plus": "^2.13.0",
|
"element-plus": "^2.14.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.40",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24",
|
"@types/node": "24",
|
||||||
@@ -23,19 +24,19 @@
|
|||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"eslint": "^9.39.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-vue": "^9.33.0",
|
"eslint-plugin-vue": "^10.10.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.9.6",
|
||||||
"sass": "^1.97.2",
|
"sass": "^1.102.0",
|
||||||
"terser": "^5.44.1",
|
"terser": "^5.49.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^0.17.5",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-element-plus": "^0.11.2",
|
"unplugin-element-plus": "^0.11.2",
|
||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.0",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue-tsc": "^3.2.2"
|
"vue-tsc": "^3.3.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- number -->
|
<!-- number -->
|
||||||
<el-input
|
<el-input
|
||||||
v-else-if="type === 'number'"
|
v-else-if="type === 'number'"
|
||||||
:model-value="modelValue != null ? String(modelValue) : ''"
|
:model-value="numberDraft"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@update:model-value="handleNumberInput($event)"
|
@update:model-value="handleNumberInput($event)"
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import KeyValueEditor from './KeyValueEditor.vue'
|
import KeyValueEditor from './KeyValueEditor.vue'
|
||||||
import StringListEditor from './StringListEditor.vue'
|
import StringListEditor from './StringListEditor.vue'
|
||||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||||
@@ -154,16 +154,42 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: any]
|
'update:modelValue': [value: any]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const numberDraft = ref(
|
||||||
|
props.modelValue != null ? String(props.modelValue) : '',
|
||||||
|
)
|
||||||
|
const preserveNumberDraft = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (preserveNumberDraft.value && value == null) {
|
||||||
|
preserveNumberDraft.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preserveNumberDraft.value = false
|
||||||
|
numberDraft.value = value != null ? String(value) : ''
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const handleNumberInput = (val: string) => {
|
const handleNumberInput = (val: string) => {
|
||||||
|
numberDraft.value = val
|
||||||
if (val === '') {
|
if (val === '') {
|
||||||
emit('update:modelValue', undefined)
|
emit('update:modelValue', undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Keep a leading sign as an editing state so negative values can be typed
|
||||||
|
// naturally. The form value is updated once the input becomes a number.
|
||||||
|
if (val === '-' || val === '+') {
|
||||||
|
preserveNumberDraft.value = true
|
||||||
|
emit('update:modelValue', undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
const num = Number(val)
|
const num = Number(val)
|
||||||
if (!isNaN(num)) {
|
if (!isNaN(num)) {
|
||||||
let clamped = num
|
let clamped = num
|
||||||
if (props.min != null && clamped < props.min) clamped = props.min
|
if (props.min != null && clamped < props.min) clamped = props.min
|
||||||
if (props.max != null && clamped > props.max) clamped = props.max
|
if (props.max != null && clamped > props.max) clamped = props.max
|
||||||
|
numberDraft.value = String(clamped)
|
||||||
emit('update:modelValue', clamped)
|
emit('update:modelValue', clamped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
|
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
|
||||||
placeholder="127.0.0.1" :readonly="readonly" />
|
placeholder="127.0.0.1" :readonly="readonly" />
|
||||||
<ConfigField label="Bind Port" type="number" v-model="form.bindPort"
|
<ConfigField label="Bind Port" type="number" v-model="form.bindPort"
|
||||||
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly" />
|
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly"
|
||||||
|
:tip="bindPortTip" />
|
||||||
</div>
|
</div>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,6 +37,9 @@ const form = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
|
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
|
||||||
|
const bindPortTip = computed(() => form.value.type === 'sudp'
|
||||||
|
? ''
|
||||||
|
: 'Use -1 to skip the local listener when connections come from another visitor or plugin.')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
|
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
<VisitorConnectionSection v-model="form" :readonly="readonly" />
|
<VisitorConnectionSection v-model="form" :readonly="readonly" />
|
||||||
|
<VisitorPluginSection
|
||||||
|
v-if="form.type === 'stcp' || form.type === 'xtcp'"
|
||||||
|
v-model="form"
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
<VisitorTransportSection v-model="form" :readonly="readonly" />
|
<VisitorTransportSection v-model="form" :readonly="readonly" />
|
||||||
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
|
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
|
||||||
</div>
|
</div>
|
||||||
@@ -15,6 +20,7 @@ import type { VisitorFormData } from '../../types'
|
|||||||
import ConfigSection from '../ConfigSection.vue'
|
import ConfigSection from '../ConfigSection.vue'
|
||||||
import VisitorBaseSection from './VisitorBaseSection.vue'
|
import VisitorBaseSection from './VisitorBaseSection.vue'
|
||||||
import VisitorConnectionSection from './VisitorConnectionSection.vue'
|
import VisitorConnectionSection from './VisitorConnectionSection.vue'
|
||||||
|
import VisitorPluginSection from './VisitorPluginSection.vue'
|
||||||
import VisitorTransportSection from './VisitorTransportSection.vue'
|
import VisitorTransportSection from './VisitorTransportSection.vue'
|
||||||
import VisitorXtcpSection from './VisitorXtcpSection.vue'
|
import VisitorXtcpSection from './VisitorXtcpSection.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<ConfigSection title="Plugin" :readonly="readonly">
|
||||||
|
<div class="field-row two-col">
|
||||||
|
<ConfigField
|
||||||
|
label="Plugin Type"
|
||||||
|
type="select"
|
||||||
|
v-model="form.pluginType"
|
||||||
|
:options="pluginOptions"
|
||||||
|
placeholder="None"
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
|
<ConfigField
|
||||||
|
v-if="form.pluginType === 'virtual_net'"
|
||||||
|
label="Destination IP"
|
||||||
|
type="text"
|
||||||
|
v-model="form.pluginDestinationIP"
|
||||||
|
prop="pluginDestinationIP"
|
||||||
|
placeholder="10.10.10.10"
|
||||||
|
tip="Destination address in the frp virtual network."
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ConfigSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { VisitorFormData } from '../../types'
|
||||||
|
import ConfigField from '../ConfigField.vue'
|
||||||
|
import ConfigSection from '../ConfigSection.vue'
|
||||||
|
|
||||||
|
const pluginOptions = [
|
||||||
|
{ label: 'None', value: '' },
|
||||||
|
{ label: 'virtual_net', value: 'virtual_net' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: VisitorFormData
|
||||||
|
readonly?: boolean
|
||||||
|
}>(), { readonly: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
|
||||||
|
|
||||||
|
const form = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/assets/css/form-layout';
|
||||||
|
</style>
|
||||||
@@ -190,6 +190,16 @@ export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition {
|
|||||||
block.bindPort = form.bindPort
|
block.bindPort = form.bindPort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
form.pluginType === 'virtual_net' &&
|
||||||
|
(form.type === 'stcp' || form.type === 'xtcp')
|
||||||
|
) {
|
||||||
|
block.plugin = {
|
||||||
|
type: 'virtual_net',
|
||||||
|
destinationIP: form.pluginDestinationIP.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (form.type === 'xtcp') {
|
if (form.type === 'xtcp') {
|
||||||
if (form.protocol && form.protocol !== 'quic') {
|
if (form.protocol && form.protocol !== 'quic') {
|
||||||
block.protocol = form.protocol
|
block.protocol = form.protocol
|
||||||
@@ -448,6 +458,15 @@ export function storeVisitorToForm(
|
|||||||
form.bindAddr = c.bindAddr || '127.0.0.1'
|
form.bindAddr = c.bindAddr || '127.0.0.1'
|
||||||
form.bindPort = c.bindPort
|
form.bindPort = c.bindPort
|
||||||
|
|
||||||
|
// Visitor plugin (only supported by stcp/xtcp; sudp runtime never uses it)
|
||||||
|
if (
|
||||||
|
c.plugin?.type === 'virtual_net' &&
|
||||||
|
(form.type === 'stcp' || form.type === 'xtcp')
|
||||||
|
) {
|
||||||
|
form.pluginType = 'virtual_net'
|
||||||
|
form.pluginDestinationIP = c.plugin.destinationIP || ''
|
||||||
|
}
|
||||||
|
|
||||||
// XTCP specific
|
// XTCP specific
|
||||||
form.protocol = c.protocol || 'quic'
|
form.protocol = c.protocol || 'quic'
|
||||||
form.keepTunnelOpen = c.keepTunnelOpen || false
|
form.keepTunnelOpen = c.keepTunnelOpen || false
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export interface VisitorFormData {
|
|||||||
bindAddr: string
|
bindAddr: string
|
||||||
bindPort: number | undefined
|
bindPort: number | undefined
|
||||||
|
|
||||||
|
// Visitor plugin
|
||||||
|
pluginType: '' | 'virtual_net'
|
||||||
|
pluginDestinationIP: string
|
||||||
|
|
||||||
// XTCP specific (XTCPVisitorConfig)
|
// XTCP specific (XTCPVisitorConfig)
|
||||||
protocol: string
|
protocol: string
|
||||||
keepTunnelOpen: boolean
|
keepTunnelOpen: boolean
|
||||||
@@ -156,6 +160,9 @@ export function createDefaultVisitorForm(): VisitorFormData {
|
|||||||
bindAddr: '127.0.0.1',
|
bindAddr: '127.0.0.1',
|
||||||
bindPort: undefined,
|
bindPort: undefined,
|
||||||
|
|
||||||
|
pluginType: '',
|
||||||
|
pluginDestinationIP: '',
|
||||||
|
|
||||||
protocol: 'quic',
|
protocol: 'quic',
|
||||||
keepTunnelOpen: false,
|
keepTunnelOpen: false,
|
||||||
maxRetriesAnHour: undefined,
|
maxRetriesAnHour: undefined,
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ const formRules: FormRules = {
|
|||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
pluginDestinationIP: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (
|
||||||
|
form.value.pluginType === 'virtual_net' &&
|
||||||
|
(form.value.type === 'stcp' || form.value.type === 'xtcp') &&
|
||||||
|
!value?.trim()
|
||||||
|
) {
|
||||||
|
callback(new Error('Destination IP is required for virtual_net'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ConfigField from '../src/components/ConfigField.vue'
|
||||||
|
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: ['modelValue', 'disabled', 'placeholder', 'type'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template:
|
||||||
|
'<input :value="modelValue ?? \'\'" :disabled="disabled" :placeholder="placeholder" :type="type === \'password\' ? \'password\' : \'text\'" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
const FormItemStub = defineComponent({
|
||||||
|
template: '<div class="form-item-stub"><slot /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
'el-form-item': FormItemStub,
|
||||||
|
'el-input': InputStub,
|
||||||
|
'el-switch': defineComponent({
|
||||||
|
props: ['modelValue', 'disabled'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: '<button :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)">switch</button>',
|
||||||
|
}),
|
||||||
|
KeyValueEditor: defineComponent({ template: '<div class="key-value-stub" />' }),
|
||||||
|
StringListEditor: defineComponent({ template: '<div class="string-list-stub" />' }),
|
||||||
|
PopoverMenu: defineComponent({ template: '<div class="popover-stub"><slot /></div>' }),
|
||||||
|
PopoverMenuItem: defineComponent({ template: '<div><slot /></div>' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfigField', () => {
|
||||||
|
it('clamps numeric input and emits the public model update', async () => {
|
||||||
|
const wrapper = mount(ConfigField, {
|
||||||
|
props: { label: 'Port', type: 'number', modelValue: 100, min: 1, max: 65535 },
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
await input.setValue('70000')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toContainEqual([65535])
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('65535')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a leading sign as an editable draft until it becomes numeric', async () => {
|
||||||
|
const wrapper = mount(ConfigField, {
|
||||||
|
props: { label: 'Port', type: 'number', modelValue: 100 },
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
await wrapper.setProps({ modelValue: 200 })
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('200')
|
||||||
|
|
||||||
|
await input.setValue('-')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toContainEqual([undefined])
|
||||||
|
await wrapper.setProps({ modelValue: undefined })
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an empty readonly field as a disabled placeholder', () => {
|
||||||
|
const wrapper = mount(ConfigField, {
|
||||||
|
props: { label: 'Secret', readonly: true, modelValue: '' },
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = wrapper.find('input').element as HTMLInputElement
|
||||||
|
expect(wrapper.find('.config-field-label').text()).toBe('Secret')
|
||||||
|
expect(input.disabled).toBe(true)
|
||||||
|
expect(input.value).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
createDefaultProxyForm,
|
||||||
|
createDefaultVisitorForm,
|
||||||
|
} from '../src/types/proxy-form'
|
||||||
|
import {
|
||||||
|
formToStoreProxy,
|
||||||
|
formToStoreVisitor,
|
||||||
|
storeProxyToForm,
|
||||||
|
} from '../src/types/proxy-converters'
|
||||||
|
|
||||||
|
describe('proxy converters', () => {
|
||||||
|
it('serializes only configured proxy fields into the typed store block', () => {
|
||||||
|
const form = createDefaultProxyForm()
|
||||||
|
Object.assign(form, {
|
||||||
|
name: 'web',
|
||||||
|
type: 'http',
|
||||||
|
enabled: false,
|
||||||
|
localIP: '10.0.0.8',
|
||||||
|
localPort: 8080,
|
||||||
|
useEncryption: true,
|
||||||
|
customDomains: ['example.com', ''],
|
||||||
|
locations: ['/api', ''],
|
||||||
|
metadatas: [{ key: 'team', value: 'edge' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(formToStoreProxy(form)).toEqual({
|
||||||
|
name: 'web',
|
||||||
|
type: 'http',
|
||||||
|
http: {
|
||||||
|
enabled: false,
|
||||||
|
localIP: '10.0.0.8',
|
||||||
|
localPort: 8080,
|
||||||
|
transport: { useEncryption: true },
|
||||||
|
metadatas: { team: 'edge' },
|
||||||
|
customDomains: ['example.com'],
|
||||||
|
locations: ['/api'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips store values while applying form defaults', () => {
|
||||||
|
const form = storeProxyToForm({
|
||||||
|
name: 'tcp-proxy',
|
||||||
|
type: 'tcp',
|
||||||
|
tcp: {
|
||||||
|
localPort: 9000,
|
||||||
|
customDomains: 'unused-for-tcp',
|
||||||
|
enabled: false,
|
||||||
|
metadatas: { owner: 'platform' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(form).toMatchObject({
|
||||||
|
name: 'tcp-proxy',
|
||||||
|
type: 'tcp',
|
||||||
|
enabled: false,
|
||||||
|
localIP: '127.0.0.1',
|
||||||
|
localPort: 9000,
|
||||||
|
metadatas: [{ key: 'owner', value: 'platform' }],
|
||||||
|
multiplexer: 'httpconnect',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['stcp', 'xtcp'] as const)(
|
||||||
|
'serializes virtual_net for %s visitors',
|
||||||
|
(type) => {
|
||||||
|
const form = createDefaultVisitorForm()
|
||||||
|
Object.assign(form, {
|
||||||
|
name: 'visitor',
|
||||||
|
type,
|
||||||
|
pluginType: 'virtual_net',
|
||||||
|
pluginDestinationIP: ' 192.0.2.10 ',
|
||||||
|
bindPort: 6000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(formToStoreVisitor(form)[type]).toMatchObject({
|
||||||
|
plugin: { type: 'virtual_net', destinationIP: '192.0.2.10' },
|
||||||
|
bindPort: 6000,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('does not serialize virtual_net for SUDP visitors', () => {
|
||||||
|
const form = createDefaultVisitorForm()
|
||||||
|
Object.assign(form, {
|
||||||
|
name: 'visitor',
|
||||||
|
type: 'sudp',
|
||||||
|
pluginType: 'virtual_net',
|
||||||
|
pluginDestinationIP: '192.0.2.10',
|
||||||
|
bindPort: 6000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(formToStoreVisitor(form).sudp).toEqual({ bindPort: 6000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.mts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user