forked from Mxmilu666/frp
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ff5557571
|
|||
|
bce3d5a3d8
|
|||
|
33a29b04e4
|
|||
|
481ccad05e
|
|||
|
521542b796
|
|||
|
e487e7f96f
|
|||
|
2f73a38a2f
|
|||
|
a9eb5e6901
|
|||
|
386df7f426
|
|||
|
79d5cc00ab
|
|||
|
d43a4e8950
|
|||
|
d205f2bb35
|
|||
|
a2b9062f2c
|
|||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 | ||
|
26f3af3fdd
|
|||
|
7d79b83a39
|
|||
|
e3ea471e44
|
|||
|
e66f45d8be
|
|||
|
cc0b8d0f94
|
|||
|
27237542c8
|
|||
|
406ea5ebee
|
|||
|
81a92d19d5
|
|||
|
83847f32ed
|
|||
|
1f2c26761d
|
|||
|
c836e276a7
|
|||
| d16f07d3e8 | |||
|
6bd5eb92c3
|
|||
|
09602f5d74
|
|||
|
d7559b39e2
|
|||
|
72d147dfa5
|
|||
|
481121a6c2
|
|||
|
be252de683
|
|||
|
0a99c1071b
|
|||
|
dd37b2e199
|
|||
|
803e548f42
|
|||
|
2dac44ac2e
|
|||
|
655dc3cb2a
|
|||
|
9894342f46
|
|||
|
e7cc706c86
|
|||
|
92ac2b9153
|
|||
|
1ed369e962
|
|||
|
b74a8d0232
|
|||
|
d2180081a0
|
|||
|
51f4e065b5
|
|||
|
e58f774086
|
|||
|
178e381a26
|
|||
|
26b93ae3a3
|
|||
|
a2aeee28e4
|
|||
|
0416caef71
|
|||
|
1004473e42
|
|||
|
f386996928
|
|||
|
4eb4b202c5
|
|||
|
ac5bdad507
|
|||
|
42f4ea7f87
|
|||
|
36e5ac094b
|
|||
|
72f79d3357
|
|||
|
e1f905f63f
|
|||
|
eb58f09268
|
|||
|
46955ffc80
|
|||
|
2d63296576
|
|||
|
a76ba823ee
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,4 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [fatedier]
|
|
||||||
custom: ["https://afdian.com/a/fatedier"]
|
|
||||||
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
@@ -1,3 +0,0 @@
|
|||||||
### WHY
|
|
||||||
|
|
||||||
<!-- author to complete -->
|
|
||||||
194
.github/workflows/build-all.yaml
vendored
Normal file
194
.github/workflows/build-all.yaml
vendored
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
name: Build FRP Binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
goos: [linux, windows, darwin, freebsd, openbsd, android]
|
||||||
|
goarch: [amd64, 386, arm, arm64]
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: loong64
|
||||||
|
exclude:
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: 386
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: arm
|
||||||
|
- goos: openbsd
|
||||||
|
goarch: arm
|
||||||
|
- goos: android
|
||||||
|
goarch: amd64
|
||||||
|
- goos: android
|
||||||
|
goarch: 386
|
||||||
|
# 排除 Android ARM 32位,在单独的 job 中处理
|
||||||
|
- goos: android
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y zip tar make gcc g++ upx
|
||||||
|
|
||||||
|
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
run: |
|
||||||
|
mkdir -p release/packages
|
||||||
|
|
||||||
|
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||||
|
|
||||||
|
# 构建版本号
|
||||||
|
make
|
||||||
|
version=$(./bin/frps --version)
|
||||||
|
echo "Detected version: $version"
|
||||||
|
|
||||||
|
export GOOS=${{ matrix.goos }}
|
||||||
|
export GOARCH=${{ matrix.goarch }}
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
|
||||||
|
# 构建可执行文件
|
||||||
|
make frpc frps
|
||||||
|
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
|
||||||
|
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
mv ./bin/frpc.exe "$out_dir/frpc.exe"
|
||||||
|
mv ./bin/frps.exe "$out_dir/frps.exe"
|
||||||
|
else
|
||||||
|
mv ./bin/frpc "$out_dir/frpc"
|
||||||
|
mv ./bin/frps "$out_dir/frps"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp LICENSE "$out_dir"
|
||||||
|
cp -f conf/frpc.toml "$out_dir"
|
||||||
|
cp -f conf/frps.toml "$out_dir"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
path: |
|
||||||
|
release/packages/frp_*
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-android-arm:
|
||||||
|
name: Build FRP android-arm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
|
||||||
|
- name: Install dependencies and Android NDK
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
|
||||||
|
|
||||||
|
# 下载并安装 Android NDK
|
||||||
|
echo "Downloading Android NDK..."
|
||||||
|
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
|
||||||
|
echo "Extracting Android NDK..."
|
||||||
|
unzip -q android-ndk-r26c-linux.zip
|
||||||
|
echo "NDK installed at: $PWD/android-ndk-r26c"
|
||||||
|
|
||||||
|
- name: Build FRP for android-arm
|
||||||
|
run: |
|
||||||
|
mkdir -p release/packages
|
||||||
|
mkdir -p bin
|
||||||
|
|
||||||
|
echo "Building for android-arm with CGO"
|
||||||
|
|
||||||
|
# 首先构建一次获取版本号
|
||||||
|
CGO_ENABLED=0 make
|
||||||
|
version=$(./bin/frps --version)
|
||||||
|
echo "Detected version: $version"
|
||||||
|
|
||||||
|
# 清理之前的构建
|
||||||
|
rm -rf ./bin/*
|
||||||
|
|
||||||
|
# 设置 Android ARM 交叉编译环境
|
||||||
|
export GOOS=android
|
||||||
|
export GOARCH=arm
|
||||||
|
export GOARM=7
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
|
||||||
|
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
|
||||||
|
|
||||||
|
echo "Environment:"
|
||||||
|
echo "GOOS=$GOOS"
|
||||||
|
echo "GOARCH=$GOARCH"
|
||||||
|
echo "GOARM=$GOARM"
|
||||||
|
echo "CGO_ENABLED=$CGO_ENABLED"
|
||||||
|
echo "CC=$CC"
|
||||||
|
|
||||||
|
# 直接使用 go build 命令,不通过 Makefile,防止 CGO_ENABLED 被覆盖
|
||||||
|
# -checklinkname=0: 关闭 Go 1.23+ 的 linkname 检查,规避 wlynxg/anet
|
||||||
|
# 在 android 下通过 //go:linkname 引用 net.zoneCache 导致的链接失败
|
||||||
|
echo "Building frps..."
|
||||||
|
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frps -o bin/frps ./cmd/frps
|
||||||
|
|
||||||
|
echo "Building frpc..."
|
||||||
|
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frpc -o bin/frpc ./cmd/frpc
|
||||||
|
|
||||||
|
# 验证文件已生成
|
||||||
|
ls -lh ./bin/
|
||||||
|
file ./bin/frpc
|
||||||
|
file ./bin/frps
|
||||||
|
|
||||||
|
out_dir="release/packages/frp_${version}_android_arm"
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
|
||||||
|
mv ./bin/frpc "$out_dir/frpc"
|
||||||
|
mv ./bin/frps "$out_dir/frps"
|
||||||
|
|
||||||
|
cp LICENSE "$out_dir"
|
||||||
|
cp -f conf/frpc.toml "$out_dir"
|
||||||
|
cp -f conf/frps.toml "$out_dir"
|
||||||
|
|
||||||
|
echo "Build completed for android-arm"
|
||||||
|
ls -lh "$out_dir"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: LoliaFrp_android_arm
|
||||||
|
path: |
|
||||||
|
release/packages/frp_*
|
||||||
|
retention-days: 7
|
||||||
83
.github/workflows/build-and-push-image.yml
vendored
83
.github/workflows/build-and-push-image.yml
vendored
@@ -1,83 +0,0 @@
|
|||||||
name: Build Image and Publish to Dockerhub & GPR
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [ published ]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Image tag'
|
|
||||||
required: true
|
|
||||||
default: 'test'
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
image:
|
|
||||||
name: Build Image from Dockerfile and binaries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# environment
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: '0'
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
|
|
||||||
# get image tag name
|
|
||||||
- name: Get Image Tag Name
|
|
||||||
run: |
|
|
||||||
if [ x${{ github.event.inputs.tag }} == x"" ]; then
|
|
||||||
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to the GPR
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GPR_TOKEN }}
|
|
||||||
|
|
||||||
# prepare image tags
|
|
||||||
- name: Prepare Image Tags
|
|
||||||
run: |
|
|
||||||
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
|
|
||||||
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
|
|
||||||
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
|
||||||
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
|
||||||
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
|
||||||
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build and push frpc
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./dockerfiles/Dockerfile-for-frpc
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.TAG_FRPC }}
|
|
||||||
${{ env.TAG_FRPC_GPR }}
|
|
||||||
|
|
||||||
- name: Build and push frps
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./dockerfiles/Dockerfile-for-frps
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.TAG_FRPS }}
|
|
||||||
${{ env.TAG_FRPS_GPR }}
|
|
||||||
82
.github/workflows/docker-build.yml
vendored
Normal file
82
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Docker Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: frps
|
||||||
|
dockerfile: dockerfiles/Dockerfile-for-frps
|
||||||
|
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
|
||||||
|
- name: frpc
|
||||||
|
dockerfile: dockerfiles/Dockerfile-for-frpc
|
||||||
|
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ matrix.image_name }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.dockerfile }}
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
|
||||||
|
- name: Generate image digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
echo "Image pushed to: ${{ matrix.image_name }}"
|
||||||
|
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
||||||
129
.github/workflows/release.yaml
vendored
Normal file
129
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: Release FRP Binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag to release (e.g., v1.0.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 调用 build-all workflow
|
||||||
|
build:
|
||||||
|
uses: ./.github/workflows/build-all.yaml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# 创建 release
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get tag name
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Display artifact structure
|
||||||
|
run: |
|
||||||
|
echo "Artifact structure:"
|
||||||
|
ls -R artifacts/
|
||||||
|
|
||||||
|
- name: Organize release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release_files
|
||||||
|
|
||||||
|
# 查找并复制所有压缩包
|
||||||
|
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
|
||||||
|
|
||||||
|
# 如果没有压缩包,尝试查找二进制文件并打包
|
||||||
|
if [ -z "$(ls -A release_files/)" ]; then
|
||||||
|
echo "No archives found, looking for directories to package..."
|
||||||
|
for dir in artifacts/*/; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
artifact_name=$(basename "$dir")
|
||||||
|
echo "Packaging $artifact_name"
|
||||||
|
|
||||||
|
# 检查是否是 Windows 构建
|
||||||
|
if echo "$artifact_name" | grep -q "windows"; then
|
||||||
|
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
|
||||||
|
else
|
||||||
|
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Files in release_files:"
|
||||||
|
ls -lh release_files/
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd release_files
|
||||||
|
if [ -n "$(ls -A .)" ]; then
|
||||||
|
sha256sum * > sha256sum.txt
|
||||||
|
cat sha256sum.txt
|
||||||
|
else
|
||||||
|
echo "No files to generate checksums for!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Debug - Check tags and commits
|
||||||
|
run: |
|
||||||
|
echo "Current tag: ${{ steps.tag.outputs.tag }}"
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
|
||||||
|
echo "Previous tag: $PREV_TAG"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Commits between tags:"
|
||||||
|
if [ "$PREV_TAG" != "none" ]; then
|
||||||
|
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
|
||||||
|
else
|
||||||
|
echo "First tag, showing all commits:"
|
||||||
|
git log --oneline ${{ steps.tag.outputs.tag }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Changelog
|
||||||
|
id: changelog
|
||||||
|
uses: requarks/changelog-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ steps.tag.outputs.tag }}
|
||||||
|
writeToFile: false
|
||||||
|
includeInvalidCommits: true
|
||||||
|
useGitmojis: false
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.tag.outputs.tag }}
|
||||||
|
name: Release ${{ steps.tag.outputs.tag }}
|
||||||
|
body: ${{ steps.changelog.outputs.changes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
release_files/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,6 +32,10 @@ node_modules/
|
|||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
|
CLAUDE.md
|
||||||
.claude/
|
.claude/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# TLS
|
||||||
|
.autotls-cache
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -1,6 +1,8 @@
|
|||||||
export PATH := $(PATH):`go env GOPATH`/bin
|
export PATH := $(PATH):`go env GOPATH`/bin
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
LDFLAGS := -s -w
|
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep),
|
||||||
|
# which uses //go:linkname to reference net.zoneCache on android targets.
|
||||||
|
LDFLAGS := -s -w -checklinkname=0
|
||||||
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
||||||
FRP_COMPAT_BASELINE_COUNT ?= 8
|
FRP_COMPAT_BASELINE_COUNT ?= 8
|
||||||
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
|
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
|
||||||
|
|||||||
26
Release.md
26
Release.md
@@ -1,21 +1,9 @@
|
|||||||
## Compatibility Policy
|
|
||||||
|
|
||||||
Starting with v0.69.0, each minor release is supported until there are nine newer minor releases. For example, v0.69.0 will be supported until v0.78.0 is released. Within this window, frpc v0.69.0 is guaranteed to work with any frps from v0.61.0 to v0.77.0, and vice versa. Patch releases within the same minor are always compatible. Versions outside the support window may continue to work on a best-effort basis, but compatibility is no longer guaranteed.
|
|
||||||
|
|
||||||
For mixed-version deployments, upgrade frps first, then upgrade frpc. This keeps the server side ready for newer client-side protocol behavior before clients start using it.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
This release introduces wire protocol v2 as a transition path for future frpc/frps protocol changes. The existing wire protocol is difficult to extend without compatibility risk, and upcoming changes, including replacing deprecated stream encryption methods, require a versioned protocol.
|
|
||||||
|
|
||||||
**The default value of `transport.wireProtocol` remains `v1` in this release.** Users can keep the default for now. To test v2 early, upgrade both frpc and frps to versions that support it, then set `transport.wireProtocol = "v2"` in frpc. A v2-enabled frpc cannot connect to an older frps.
|
|
||||||
|
|
||||||
When `transport.wireProtocol = "v2"` is enabled, the control channel uses negotiated AEAD encryption after the login handshake. Both frpc and frps must be upgraded to this release to use v2.
|
|
||||||
|
|
||||||
v1 will be deprecated when v2 becomes the default in a future release. It will continue to be supported until v0.78.0 is released, and may be removed in v0.78.0 or later.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
* `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.
|
||||||
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
|
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
||||||
* Wire protocol v2 now negotiates AEAD control-channel encryption. Supported algorithms are `xchacha20-poly1305` and `aes-256-gcm`; frpc advertises its preferred order based on local AES-GCM hardware support, and frps selects the first supported algorithm from that list.
|
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -160,9 +162,44 @@ 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] start error: %v", proxyName, err)
|
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
|
||||||
} else {
|
} else {
|
||||||
xl.Infof("[%s] start proxy success", proxyName)
|
xl.Infof("[%s] 成功启动隧道", proxyName)
|
||||||
|
if inMsg.RemoteAddr != "" {
|
||||||
|
// Get proxy type to format access message
|
||||||
|
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
|
||||||
|
proxyType := status.Type
|
||||||
|
remoteAddr := inMsg.RemoteAddr
|
||||||
|
var accessMsg string
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
|
||||||
|
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
|
||||||
|
if strings.HasPrefix(remoteAddr, ":") {
|
||||||
|
serverAddr := ctl.sessionCtx.Common.ServerAddr
|
||||||
|
remoteAddr = serverAddr + remoteAddr
|
||||||
|
}
|
||||||
|
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
||||||
|
case "http", "https":
|
||||||
|
// Format as URL with protocol
|
||||||
|
protocol := proxyType
|
||||||
|
addr := remoteAddr
|
||||||
|
// Remove standard ports for cleaner URL
|
||||||
|
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
|
||||||
|
addr = strings.TrimSuffix(addr, ":80")
|
||||||
|
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
|
||||||
|
addr = strings.TrimSuffix(addr, ":443")
|
||||||
|
}
|
||||||
|
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
|
||||||
|
default:
|
||||||
|
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
xl.Infof("[%s] %s", proxyName, accessMsg)
|
||||||
|
} else {
|
||||||
|
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ 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,
|
||||||
@@ -88,6 +91,7 @@ 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
|
||||||
@@ -107,6 +111,7 @@ 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 {
|
||||||
@@ -117,6 +122,39 @@ 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("proxy removed: %s", delPxyNames)
|
xl.Infof("隧道移除: %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("proxy added: %s", addPxyNames)
|
xl.Infof("添加隧道: %s", addPxyNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
|
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
|
||||||
readCh := make(chan *msg.UDPPacket, 1024)
|
readCh := make(chan *msg.UDPPacket, 1024)
|
||||||
sendCh := make(chan msg.Message, 1024)
|
sendCh := make(chan msg.Message, 1024)
|
||||||
isClose := false
|
isClose := false
|
||||||
@@ -109,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
|
||||||
defer closeFn()
|
defer closeFn()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -122,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var udpMsg msg.UDPPacket
|
var udpMsg msg.UDPPacket
|
||||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
|
||||||
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
|
||||||
defer func() {
|
defer func() {
|
||||||
closeFn()
|
closeFn()
|
||||||
xl.Infof("writer goroutine for sudp work connection closed")
|
xl.Infof("writer goroutine for sudp work connection closed")
|
||||||
@@ -148,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.UDPPacket:
|
case *msg.UDPPacket:
|
||||||
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
||||||
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
|
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
|
||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Tracef("frpc send ping message to frpc visitor")
|
xl.Tracef("frpc send ping message to frpc visitor")
|
||||||
}
|
}
|
||||||
|
|
||||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
|
||||||
xl.Errorf("sudp work write error: %v", errRet)
|
xl.Errorf("sudp work write error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(workConn, sendCh)
|
go workConnSenderFn(payloadConn, sendCh)
|
||||||
go workConnReaderFn(workConn, readCh)
|
go workConnReaderFn(payloadConn, readCh)
|
||||||
go heartbeatFn(sendCh)
|
go heartbeatFn(sendCh)
|
||||||
|
|
||||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||||
|
|||||||
@@ -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("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
|
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
|
||||||
// close resources related with old workConn
|
// close resources related with old workConn
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
|
|
||||||
@@ -99,15 +99,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
|
|
||||||
pxy.mu.Lock()
|
pxy.mu.Lock()
|
||||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
|
// Plain UDP payload follows the configured wire protocol for message framing.
|
||||||
|
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
|
||||||
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
||||||
pxy.sendCh = make(chan msg.Message, 1024)
|
pxy.sendCh = make(chan msg.Message, 1024)
|
||||||
pxy.closed = false
|
pxy.closed = false
|
||||||
pxy.mu.Unlock()
|
pxy.mu.Unlock()
|
||||||
|
|
||||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||||
for {
|
for {
|
||||||
var udpMsg msg.UDPPacket
|
var udpMsg msg.UDPPacket
|
||||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
|
||||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +122,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
|
||||||
defer func() {
|
defer func() {
|
||||||
xl.Infof("writer goroutine for udp work connection closed")
|
xl.Infof("writer goroutine for udp work connection closed")
|
||||||
}()
|
}()
|
||||||
@@ -132,7 +134,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Tracef("send ping message to udp workConn")
|
xl.Tracef("send ping message to udp workConn")
|
||||||
}
|
}
|
||||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
|
||||||
xl.Errorf("udp work write error: %v", errRet)
|
xl.Errorf("udp work write error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||||
go heartbeatFn(pxy.sendCh)
|
go heartbeatFn(pxy.sendCh)
|
||||||
|
|
||||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
|||||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var natHoleSidMsg msg.NatHoleSid
|
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||||
return
|
return
|
||||||
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
|||||||
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
|
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
|
||||||
|
workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
|
||||||
|
var natHoleSidMsg msg.NatHoleSid
|
||||||
|
if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &natHoleSidMsg, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
|
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
listenConn.Close()
|
listenConn.Close()
|
||||||
|
|||||||
66
client/proxy/xtcp_test.go
Normal file
66
client/proxy/xtcp_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
wireProtocol string
|
||||||
|
}{
|
||||||
|
{name: "v2", wireProtocol: wire.ProtocolV2},
|
||||||
|
{name: "v1", wireProtocol: wire.ProtocolV1},
|
||||||
|
{name: "default", wireProtocol: ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
setPipeDeadline(t, client, server)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
|
||||||
|
errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
|
||||||
|
}()
|
||||||
|
|
||||||
|
out, err := readNatHoleSid(client, tc.wireProtocol)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "sid", out.Sid)
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for _, conn := range conns {
|
||||||
|
require.NoError(t, conn.SetDeadline(deadline))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,7 +260,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("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go svr.keepControllerWorking()
|
go svr.keepControllerWorking()
|
||||||
@@ -305,7 +305,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("try to connect to server...")
|
xl.Infof("尝试连接到服务器...")
|
||||||
dialer := &controlSessionDialer{
|
dialer := &controlSessionDialer{
|
||||||
ctx: svr.ctx,
|
ctx: svr.ctx,
|
||||||
common: svr.common,
|
common: svr.common,
|
||||||
@@ -316,7 +316,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("connect to server error: %v", err)
|
xl.Warnf("连接服务器错误: %v", err)
|
||||||
if firstLoginExit {
|
if firstLoginExit {
|
||||||
svr.cancel(cancelErr{Err: err})
|
svr.cancel(cancelErr{Err: err})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
|
|||||||
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
xl := xlog.FromContextSafe(sv.ctx)
|
||||||
xl.Debugf("starting sudp proxy worker")
|
xl.Debugf("starting sudp proxy worker")
|
||||||
|
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
closeCh := make(chan struct{})
|
closeCh := make(chan struct{})
|
||||||
|
|
||||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||||
workConnReaderFn := func(conn net.Conn) {
|
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
conn.Close()
|
payloadConn.Close()
|
||||||
close(closeCh)
|
close(closeCh)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
@@ -133,13 +134,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Debugf("frpc visitor get ping message from frpc")
|
xl.Debugf("frpc visitor get ping message from frpc")
|
||||||
@@ -157,15 +158,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||||
workConnSenderFn := func(conn net.Conn) {
|
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
conn.Close()
|
payloadConn.Close()
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var errRet error
|
var errRet error
|
||||||
if firstPacket != nil {
|
if firstPacket != nil {
|
||||||
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
|
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
|
||||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,7 +181,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,8 +192,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnReaderFn(workConn)
|
go workConnReaderFn(payloadConn)
|
||||||
go workConnSenderFn(workConn)
|
go workConnSenderFn(payloadConn)
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
xl.Infof("sudp worker is closed")
|
xl.Infof("sudp worker is closed")
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ 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) {
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||||
|
fmt.Println("frpc: the configuration file is not specified")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -48,8 +48,19 @@ 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
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
var cfg *v1.ClientCommonConfig
|
||||||
if err != nil {
|
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
|
||||||
|
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||||
|
if err != nil {
|
||||||
|
cfg = &v1.ClientCommonConfig{}
|
||||||
|
if err := cfg.Complete(); err != nil {
|
||||||
|
fmt.Printf("failed to complete config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = loaded
|
||||||
|
}
|
||||||
|
} else {
|
||||||
cfg = &v1.ClientCommonConfig{}
|
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,11 +141,13 @@ 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)
|
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ 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"
|
||||||
@@ -35,24 +38,28 @@ 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 (
|
||||||
cfgFile string
|
cfgFiles []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().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
|
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
rootCmd.PersistentFlags().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, ", ")))
|
||||||
}
|
}
|
||||||
@@ -68,15 +75,30 @@ 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(cfgFile, unsafeFeatures)
|
err := runClient(cfgFiles[0], unsafeFeatures)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -106,6 +128,29 @@ 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 {
|
||||||
@@ -186,7 +231,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(
|
||||||
@@ -194,12 +239,25 @@ 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("start frpc service for config file [%s] with aggregated configuration", cfgFile)
|
log.Infof("启动 frpc 服务 [%s]", cfgFile)
|
||||||
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
|
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
|
||||||
}
|
}
|
||||||
svr, err := client.NewService(client.ServiceOptions{
|
svr, err := client.NewService(client.ServiceOptions{
|
||||||
Common: cfg,
|
Common: cfg,
|
||||||
@@ -217,3 +275,189 @@ 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,11 +33,12 @@ 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 cfgFile == "" {
|
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||||
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)
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ localIP = "127.0.0.1"
|
|||||||
localPort = 8000
|
localPort = 8000
|
||||||
subdomain = "web02"
|
subdomain = "web02"
|
||||||
customDomains = ["web02.yourdomain.com"]
|
customDomains = ["web02.yourdomain.com"]
|
||||||
|
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
|
||||||
|
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
|
||||||
|
# on the same domain always takes precedence over the redirect.
|
||||||
|
# httpRedirect = true
|
||||||
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
|
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
|
||||||
# v1 or v2 or empty
|
# v1 or v2 or empty
|
||||||
transport.proxyProtocolVersion = "v2"
|
transport.proxyProtocolVersion = "v2"
|
||||||
@@ -336,6 +340,14 @@ 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"
|
||||||
|
|
||||||
@@ -348,6 +360,14 @@ 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"
|
||||||
|
|
||||||
@@ -361,6 +381,15 @@ 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"
|
||||||
@@ -380,6 +409,14 @@ 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"
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ transport.maxPoolCount = 5
|
|||||||
# If negative, keep-alive probes are disabled.
|
# If negative, keep-alive probes are disabled.
|
||||||
# transport.tcpKeepalive = 7200
|
# transport.tcpKeepalive = 7200
|
||||||
|
|
||||||
|
# proxyIdleTimeout specifies the maximum time in seconds that a proxied user connection
|
||||||
|
# can stay open without any traffic in either direction before frps closes it.
|
||||||
|
# It reclaims connections whose endpoints are still open at the TCP level but will never send data again.
|
||||||
|
# By default, this value is 0, which disables the idle timeout.
|
||||||
|
# transport.proxyIdleTimeout = 7200
|
||||||
|
|
||||||
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
|
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
|
||||||
transport.tls.force = false
|
transport.tls.force = false
|
||||||
|
|
||||||
@@ -52,6 +58,12 @@ transport.tls.force = false
|
|||||||
vhostHTTPPort = 80
|
vhostHTTPPort = 80
|
||||||
vhostHTTPSPort = 443
|
vhostHTTPSPort = 443
|
||||||
|
|
||||||
|
# Port used in the Location header when redirecting HTTP requests to HTTPS for
|
||||||
|
# proxies with httpRedirect enabled. Set it when browsers reach the HTTPS vhost
|
||||||
|
# through a port mapping, so it differs from vhostHTTPSPort.
|
||||||
|
# By default, this value is vhostHTTPSPort.
|
||||||
|
# vhostHTTPSRedirectPort = 443
|
||||||
|
|
||||||
# Response header timeout(seconds) for vhost http server, default is 60s
|
# Response header timeout(seconds) for vhost http server, default is 60s
|
||||||
# vhostHTTPTimeout = 60
|
# vhostHTTPTimeout = 60
|
||||||
|
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -4,6 +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/charmbracelet/log v0.4.2
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.7.0
|
github.com/fatedier/golib v0.7.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -41,10 +43,16 @@ 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.0.5 // 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
|
||||||
@@ -53,6 +61,10 @@ 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/dtls/v3 v3.0.10 // indirect
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
@@ -61,6 +73,7 @@ require (
|
|||||||
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
|
||||||
@@ -68,7 +81,9 @@ require (
|
|||||||
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/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/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
@@ -82,3 +97,6 @@ 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
|
||||||
|
|
||||||
|
// Use the Lolia-FRP fork of golib: io.Join relays with adaptively sized buffers.
|
||||||
|
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.0.0-20260704205217-7f676961e707
|
||||||
|
|||||||
37
go.sum
37
go.sum
@@ -2,13 +2,29 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
|||||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
github.com/Azure/go-ntlmssp v0.1.0 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.0.0-20260704205217-7f676961e707 h1:ROQVjgk+RvaN8J8uR8ekUu4TKgJLXYObVeQC/0KADn4=
|
||||||
|
github.com/Lolia-FRP/golib v0.0.0-20260704205217-7f676961e707/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.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||||
@@ -20,12 +36,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
|
||||||
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 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.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||||
|
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=
|
||||||
@@ -70,8 +86,15 @@ 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/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.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=
|
||||||
@@ -105,8 +128,9 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
|
|||||||
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/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.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=
|
||||||
@@ -148,6 +172,8 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y
|
|||||||
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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
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=
|
||||||
@@ -162,6 +188,8 @@ golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-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=
|
||||||
@@ -189,6 +217,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
|||||||
@@ -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='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
|
arch_all='amd64 arm arm64'
|
||||||
extra_all='_ hf'
|
extra_all='_ hf'
|
||||||
|
|
||||||
cd ./release
|
cd ./release
|
||||||
|
|||||||
@@ -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 instead of using system default one")
|
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
|
||||||
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
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")
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ 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.
|
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
|
||||||
|
// supported, e.g. "https://1.1.1.1/dns-query".
|
||||||
DNSServer string `json:"dnsServer,omitempty"`
|
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
|
||||||
|
|||||||
@@ -369,6 +369,12 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
|
|||||||
type HTTPSProxyConfig struct {
|
type HTTPSProxyConfig struct {
|
||||||
ProxyBaseConfig
|
ProxyBaseConfig
|
||||||
DomainConfig
|
DomainConfig
|
||||||
|
|
||||||
|
// HTTPRedirect requests frps to redirect plain HTTP requests for the
|
||||||
|
// proxy's domains to their HTTPS endpoint. It only takes effect when
|
||||||
|
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
|
||||||
|
// proxy registered on the same domain.
|
||||||
|
HTTPRedirect bool `json:"httpRedirect,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||||
@@ -376,6 +382,7 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
|||||||
|
|
||||||
m.CustomDomains = c.CustomDomains
|
m.CustomDomains = c.CustomDomains
|
||||||
m.SubDomain = c.SubDomain
|
m.SubDomain = c.SubDomain
|
||||||
|
m.HTTPRedirect = c.HTTPRedirect
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||||
@@ -383,6 +390,7 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
|||||||
|
|
||||||
c.CustomDomains = m.CustomDomains
|
c.CustomDomains = m.CustomDomains
|
||||||
c.SubDomain = m.SubDomain
|
c.SubDomain = m.SubDomain
|
||||||
|
c.HTTPRedirect = m.HTTPRedirect
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@@ -24,29 +25,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PluginHTTP2HTTPS = "http2https"
|
PluginHTTP2HTTPS = "http2https"
|
||||||
PluginHTTPProxy = "http_proxy"
|
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
||||||
PluginHTTPS2HTTP = "https2http"
|
PluginHTTPProxy = "http_proxy"
|
||||||
PluginHTTPS2HTTPS = "https2https"
|
PluginHTTPS2HTTP = "https2http"
|
||||||
PluginHTTP2HTTP = "http2http"
|
PluginHTTPS2HTTPS = "https2https"
|
||||||
PluginSocks5 = "socks5"
|
PluginHTTP2HTTP = "http2http"
|
||||||
PluginStaticFile = "static_file"
|
PluginSocks5 = "socks5"
|
||||||
PluginUnixDomainSocket = "unix_domain_socket"
|
PluginStaticFile = "static_file"
|
||||||
PluginTLS2Raw = "tls2raw"
|
PluginUnixDomainSocket = "unix_domain_socket"
|
||||||
PluginVirtualNet = "virtual_net"
|
PluginTLS2Raw = "tls2raw"
|
||||||
|
PluginVirtualNet = "virtual_net"
|
||||||
)
|
)
|
||||||
|
|
||||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
|
||||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||||
|
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientPluginOptions interface {
|
type ClientPluginOptions interface {
|
||||||
@@ -80,6 +83,29 @@ 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"`
|
||||||
@@ -98,6 +124,21 @@ 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"`
|
||||||
@@ -122,6 +163,7 @@ 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() {
|
||||||
@@ -135,6 +177,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +189,7 @@ 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() {
|
||||||
@@ -159,6 +203,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +275,11 @@ 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() {}
|
||||||
@@ -243,6 +289,7 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := *o
|
out := *o
|
||||||
|
out.AutoTLS = o.AutoTLS.Clone()
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ type ServerConfig struct {
|
|||||||
// Vhost requests. If this value is 0, the server will not listen for HTTPS
|
// Vhost requests. If this value is 0, the server will not listen for HTTPS
|
||||||
// requests.
|
// requests.
|
||||||
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
|
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
|
||||||
|
// VhostHTTPSRedirectPort specifies the port used in the Location header
|
||||||
|
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
|
||||||
|
// enabled. Set it when browsers reach the HTTPS vhost through a port
|
||||||
|
// mapping, so it differs from VhostHTTPSPort. By default, this value is
|
||||||
|
// VhostHTTPSPort.
|
||||||
|
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
|
||||||
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
|
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
|
||||||
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
|
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
|
||||||
// requests on one single port. If it's not - it will listen on this value for
|
// requests on one single port. If it's not - it will listen on this value for
|
||||||
@@ -118,6 +124,7 @@ func (c *ServerConfig) Complete() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
||||||
|
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
|
||||||
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
|
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
|
||||||
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
||||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||||
@@ -173,6 +180,12 @@ type ServerTransportConfig struct {
|
|||||||
// before terminating the connection. It is not recommended to change this
|
// before terminating the connection. It is not recommended to change this
|
||||||
// value. By default, this value is 90. Set negative value to disable it.
|
// value. By default, this value is 90. Set negative value to disable it.
|
||||||
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
|
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
|
||||||
|
// ProxyIdleTimeout specifies the maximum time in seconds that a proxied
|
||||||
|
// user connection can stay open without any traffic in either direction
|
||||||
|
// before frps closes it. This reclaims connections whose endpoints are
|
||||||
|
// still open at the TCP level but will never send data again. By default,
|
||||||
|
// this value is 0, which disables the idle timeout.
|
||||||
|
ProxyIdleTimeout int64 `json:"proxyIdleTimeout,omitempty"`
|
||||||
// QUIC options.
|
// QUIC options.
|
||||||
QUIC QUICOptions `json:"quic,omitempty"`
|
QUIC QUICOptions `json:"quic,omitempty"`
|
||||||
// TLS specifies TLS settings for the connection from the client.
|
// TLS specifies TLS settings for the connection from the client.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ package validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,8 @@ 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:
|
||||||
@@ -45,10 +49,17 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +67,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,5 +91,29 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,8 +243,9 @@ 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 proxyStats.ProxyType != proxyType {
|
if !filterAll && proxyStats.ProxyType != proxyType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res = append(res, toProxyStats(name, proxyStats))
|
res = append(res, toProxyStats(name, proxyStats))
|
||||||
@@ -257,8 +258,13 @@ 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 && proxyStats.ProxyType == proxyType {
|
if ok {
|
||||||
res = toProxyStats(proxyName, proxyStats)
|
// filterAll allows the "all proxies" API to look up a proxy by name without
|
||||||
|
// knowing its type (proxyType == "" or "all").
|
||||||
|
filterAll := proxyType == "" || proxyType == "all"
|
||||||
|
if filterAll || proxyStats.ProxyType == proxyType {
|
||||||
|
res = toProxyStats(proxyName, proxyStats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ type NewProxy struct {
|
|||||||
Headers map[string]string `json:"headers,omitempty"`
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
||||||
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
||||||
|
HTTPRedirect bool `json:"http_redirect,omitempty"`
|
||||||
|
|
||||||
// stcp, sudp, xtcp
|
// stcp, sudp, xtcp
|
||||||
Sk string `json:"sk,omitempty"`
|
Sk string `json:"sk,omitempty"`
|
||||||
|
|||||||
@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
|
|||||||
func TestNewReadWriter(t *testing.T) {
|
func TestNewReadWriter(t *testing.T) {
|
||||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
|
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
|
||||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
|
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
|
||||||
|
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
|
||||||
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
|
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewReadWriterEncoding(t *testing.T) {
|
||||||
|
for _, wireProtocol := range []string{"", wire.ProtocolV1} {
|
||||||
|
var legacy bytes.Buffer
|
||||||
|
legacyRW := NewReadWriter(&legacy, wireProtocol)
|
||||||
|
require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
|
||||||
|
require.NotEmpty(t, legacy.Bytes())
|
||||||
|
require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
var v2 bytes.Buffer
|
||||||
|
v2RW := NewReadWriter(&v2, wire.ProtocolV2)
|
||||||
|
require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
|
||||||
|
frame, err := wire.NewConn(&v2).ReadFrame()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||||
|
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||||
|
}
|
||||||
|
|
||||||
func TestV2MessageTypeIDsAreStable(t *testing.T) {
|
func TestV2MessageTypeIDsAreStable(t *testing.T) {
|
||||||
require.Equal(t, uint16(1), V2TypeLogin)
|
require.Equal(t, uint16(1), V2TypeLogin)
|
||||||
require.Equal(t, uint16(2), V2TypeLoginResp)
|
require.Equal(t, uint16(2), V2TypeLoginResp)
|
||||||
|
|||||||
@@ -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", err)
|
log.Debugf("sid [%s] analysis error: %v", sid, 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,7 +385,6 @@ 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
|
||||||
@@ -394,9 +393,8 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ports = append(ports, msg.PortsRange{
|
return []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
|
|
||||||
}
|
}
|
||||||
|
|||||||
212
pkg/plugin/client/autotls.go
Normal file
212
pkg/plugin/client/autotls.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Copyright 2026 The LoliaTeam Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/acme"
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
|
||||||
|
if auto == nil || !auto.Enable {
|
||||||
|
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostSet := make(map[string]struct{})
|
||||||
|
hosts := make([]string, 0, len(auto.HostAllowList))
|
||||||
|
addHost := func(host string) {
|
||||||
|
host = strings.TrimSpace(strings.ToLower(host))
|
||||||
|
if host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(host, "*") {
|
||||||
|
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := hostSet[host]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostSet[host] = struct{}{}
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range auto.HostAllowList {
|
||||||
|
addHost(host)
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
for _, host := range fallbackHosts {
|
||||||
|
addHost(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := &autocert.Manager{
|
||||||
|
Prompt: autocert.AcceptTOS,
|
||||||
|
Email: strings.TrimSpace(auto.Email),
|
||||||
|
HostPolicy: autocert.HostWhitelist(hosts...),
|
||||||
|
}
|
||||||
|
caDirURL := strings.TrimSpace(auto.CADirURL)
|
||||||
|
if caDirURL != "" {
|
||||||
|
manager.Client = &acme.Client{DirectoryURL: caDirURL}
|
||||||
|
} else {
|
||||||
|
caDirURL = autocert.DefaultACMEDirectory
|
||||||
|
}
|
||||||
|
managedHosts := make(map[string]struct{}, len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
managedHosts[host] = struct{}{}
|
||||||
|
}
|
||||||
|
var warmupInProgress sync.Map
|
||||||
|
var warmupMissLogged sync.Map
|
||||||
|
manager.Cache = &autoTLSCache{
|
||||||
|
inner: autocert.DirCache(auto.CacheDir),
|
||||||
|
managedHosts: managedHosts,
|
||||||
|
pluginName: pluginName,
|
||||||
|
caDirURL: caDirURL,
|
||||||
|
warmupInProgress: &warmupInProgress,
|
||||||
|
warmupMissLogged: &warmupMissLogged,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := manager.TLSConfig()
|
||||||
|
log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir)
|
||||||
|
|
||||||
|
var readySeen sync.Map
|
||||||
|
|
||||||
|
handleCertReady := func(host string, cert *tls.Certificate) {
|
||||||
|
var (
|
||||||
|
notAfter time.Time
|
||||||
|
hasExpiry bool
|
||||||
|
)
|
||||||
|
if t, ok := getCertificateNotAfter(cert); ok {
|
||||||
|
notAfter = t
|
||||||
|
hasExpiry = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
|
||||||
|
if hasExpiry {
|
||||||
|
if !readyLogged {
|
||||||
|
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
} else if !readyLogged {
|
||||||
|
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
|
||||||
|
if host == "" {
|
||||||
|
host = "<空SNI>"
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := manager.GetCertificate(hello)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handleCertReady(host, cert)
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm up certificates in background after startup.
|
||||||
|
for _, host := range hosts {
|
||||||
|
h := host
|
||||||
|
go func() {
|
||||||
|
// Leave time for listener setup and route registration.
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
warmupMissLogged.Delete(h)
|
||||||
|
warmupInProgress.Store(h, struct{}{})
|
||||||
|
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
|
||||||
|
warmupInProgress.Delete(h)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleCertReady(h, cert)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
|
||||||
|
if cert == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if cert.Leaf != nil {
|
||||||
|
return cert.Leaf.NotAfter, true
|
||||||
|
}
|
||||||
|
if len(cert.Certificate) == 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return leaf.NotAfter, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type autoTLSCache struct {
|
||||||
|
inner autocert.Cache
|
||||||
|
managedHosts map[string]struct{}
|
||||||
|
pluginName string
|
||||||
|
caDirURL string
|
||||||
|
warmupInProgress *sync.Map
|
||||||
|
warmupMissLogged *sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
|
||||||
|
data, err := c.inner.Get(ctx, key)
|
||||||
|
if err != autocert.ErrCacheMiss {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.TrimSuffix(key, "+rsa")
|
||||||
|
if _, ok := c.managedHosts[host]; !ok {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if _, warming := c.warmupInProgress.Load(host); !warming {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
|
||||||
|
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-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)
|
||||||
|
}
|
||||||
111
pkg/plugin/client/http2https_redirect.go
Normal file
111
pkg/plugin/client/http2https_redirect.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Copyright 2026 The LoliaTeam Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTP2HTTPSRedirectPlugin struct {
|
||||||
|
opts *v1.HTTP2HTTPSRedirectPluginOptions
|
||||||
|
|
||||||
|
l *Listener
|
||||||
|
s *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
|
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
|
||||||
|
|
||||||
|
listener := NewProxyListener()
|
||||||
|
p := &HTTP2HTTPSRedirectPlugin{
|
||||||
|
opts: opts,
|
||||||
|
l: listener,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.s = &http.Server{
|
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Not an open redirect: the target scheme is fixed to https and the
|
||||||
|
// host is the one the client already connected to.
|
||||||
|
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound) //nolint:gosec // G710
|
||||||
|
}),
|
||||||
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = p.s.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
|
||||||
|
host := strings.TrimSpace(req.Host)
|
||||||
|
if host == "" {
|
||||||
|
host = strings.TrimSpace(req.URL.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetHost := host
|
||||||
|
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
|
||||||
|
targetHost = parsedHost
|
||||||
|
if httpsPort == 0 && parsedPort == "443" {
|
||||||
|
httpsPort = 443
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||||
|
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpsPort != 0 && httpsPort != 443 {
|
||||||
|
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (&url.URL{
|
||||||
|
Scheme: "https",
|
||||||
|
Host: targetHost,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
RawPath: req.URL.RawPath,
|
||||||
|
RawQuery: req.URL.RawQuery,
|
||||||
|
}).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
||||||
|
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
||||||
|
if connInfo.SrcAddr != nil {
|
||||||
|
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
||||||
|
}
|
||||||
|
_ = p.l.PutConn(wrapConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
|
||||||
|
return v1.PluginHTTP2HTTPSRedirect
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
|
||||||
|
return p.s.Close()
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
stdlog "log"
|
stdlog "log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -80,11 +81,27 @@ 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,
|
||||||
@@ -93,7 +110,7 @@ func newHTTPSBridgePluginServer(
|
|||||||
go func() {
|
go func() {
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
_ = p.s.ServeTLS(listener, "", "")
|
||||||
}()
|
}()
|
||||||
return p, nil
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHTTPBridgeReverseProxy(
|
func newHTTPBridgeReverseProxy(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
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"
|
||||||
@@ -32,7 +33,7 @@ type HTTPS2HTTPPlugin struct {
|
|||||||
*httpBridgePlugin
|
*httpBridgePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||||
|
|
||||||
p := &HTTPS2HTTPPlugin{
|
p := &HTTPS2HTTPPlugin{
|
||||||
@@ -49,6 +50,15 @@ func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi
|
|||||||
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,6 +18,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ type HTTPS2HTTPSPlugin struct {
|
|||||||
*httpBridgePlugin
|
*httpBridgePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||||
|
|
||||||
p := &HTTPS2HTTPSPlugin{
|
p := &HTTPS2HTTPSPlugin{
|
||||||
@@ -55,6 +56,15 @@ func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plug
|
|||||||
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,7 +33,12 @@ 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,
|
||||||
@@ -42,7 +47,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
|||||||
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, nil
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
|
|
||||||
type PluginContext struct {
|
type PluginContext struct {
|
||||||
Name string
|
Name string
|
||||||
|
HostAllowList []string
|
||||||
VnetController *vnet.Controller
|
VnetController *vnet.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,16 +39,25 @@ type TLS2RawPlugin struct {
|
|||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.TLS2RawPluginOptions)
|
opts := options.(*v1.TLS2RawPluginOptions)
|
||||||
|
|
||||||
p := &TLS2RawPlugin{
|
p := &TLS2RawPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
var tlsConfig *tls.Config
|
||||||
if err != nil {
|
var err error
|
||||||
return nil, err
|
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||||
|
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p.tlsConfig = tlsConfig
|
p.tlsConfig = tlsConfig
|
||||||
return p, nil
|
return p, nil
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
goliblog "github.com/fatedier/golib/log"
|
charmlog "github.com/charmbracelet/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 {
|
||||||
bytes.Buffer
|
mu sync.Mutex
|
||||||
levels []goliblog.Level
|
buf bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p testPlugin) Name() string {
|
func (p testPlugin) Name() string {
|
||||||
@@ -55,9 +55,36 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
|
|||||||
return p.handler(ctx, op, content)
|
return p.handler(ctx, op, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
func (w *logCapture) Write(p []byte) (int, error) {
|
||||||
w.levels = append(w.levels, level)
|
w.mu.Lock()
|
||||||
return w.Write(p)
|
defer w.mu.Unlock()
|
||||||
|
return w.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *logCapture) String() string {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// levels parses the captured JSON log output and returns the level of each entry.
|
||||||
|
func (w *logCapture) levels() []charmlog.Level {
|
||||||
|
var levels []charmlog.Level
|
||||||
|
for line := range strings.SplitSeq(strings.TrimSpace(w.String()), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var entry struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
|
||||||
|
levels = append(levels, lvl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return levels
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureLogOutput(t *testing.T) *logCapture {
|
func captureLogOutput(t *testing.T) *logCapture {
|
||||||
@@ -66,11 +93,10 @@ func captureLogOutput(t *testing.T) *logCapture {
|
|||||||
logCaptureMu.Lock()
|
logCaptureMu.Lock()
|
||||||
logOutput := &logCapture{}
|
logOutput := &logCapture{}
|
||||||
oldLogger := frplog.Logger
|
oldLogger := frplog.Logger
|
||||||
frplog.Logger = goliblog.New(
|
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
|
||||||
goliblog.WithOutput(logOutput),
|
Level: charmlog.DebugLevel,
|
||||||
goliblog.WithLevel(goliblog.TraceLevel),
|
Formatter: charmlog.JSONFormatter,
|
||||||
goliblog.WithCaller(false),
|
})
|
||||||
)
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
frplog.Logger = oldLogger
|
frplog.Logger = oldLogger
|
||||||
logCaptureMu.Unlock()
|
logCaptureMu.Unlock()
|
||||||
@@ -263,10 +289,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
op string
|
op string
|
||||||
level goliblog.Level
|
level charmlog.Level
|
||||||
}{
|
}{
|
||||||
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
|
||||||
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -288,8 +314,9 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
|||||||
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
levels := logOutput.levels()
|
||||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
if len(levels) != 1 || levels[0] != tt.level {
|
||||||
|
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -325,12 +352,13 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
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)
|
||||||
}
|
}
|
||||||
if len(logOutput.levels) != 2 {
|
levels := logOutput.levels()
|
||||||
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
if len(levels) != 2 {
|
||||||
|
t.Fatalf("expected two warning logs, got %v", levels)
|
||||||
}
|
}
|
||||||
for _, level := range logOutput.levels {
|
for _, level := range levels {
|
||||||
if level != goliblog.WarnLevel {
|
if level != charmlog.WarnLevel {
|
||||||
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
t.Fatalf("expected warning log level, got %v", levels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
pkg/util/banner/banner.go
Normal file
18
pkg/util/banner/banner.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package banner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DisplayBanner() {
|
||||||
|
fmt.Println(" __ ___ __________ ____ ________ ____")
|
||||||
|
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
|
||||||
|
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
|
||||||
|
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
|
||||||
|
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
|
||||||
|
fmt.Println(" ")
|
||||||
|
log.Infof("Nya! %s 启动中", version.Full())
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ type GeneralResponse struct {
|
|||||||
Msg string
|
Msg string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
// APIHandler is a handler function that returns a response object or an error.
|
// APIHandler is a handler function that returns a response object or an error.
|
||||||
type APIHandler func(ctx *Context) (any, error)
|
type APIHandler func(ctx *Context) (any, error)
|
||||||
|
|
||||||
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
||||||
|
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := NewContext(w, r)
|
||||||
|
res, err := handler(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
if e, ok := err.(*Error); ok {
|
||||||
|
code = e.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/log"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TraceLevel = log.TraceLevel
|
TraceLevel = log.DebugLevel
|
||||||
DebugLevel = log.DebugLevel
|
DebugLevel = log.DebugLevel
|
||||||
InfoLevel = log.InfoLevel
|
InfoLevel = log.InfoLevel
|
||||||
WarnLevel = log.WarnLevel
|
WarnLevel = log.WarnLevel
|
||||||
@@ -32,39 +37,157 @@ var (
|
|||||||
var Logger *log.Logger
|
var Logger *log.Logger
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Logger = log.New(
|
Logger = log.NewWithOptions(os.Stderr, log.Options{
|
||||||
log.WithCaller(true),
|
ReportCaller: true,
|
||||||
log.AddCallerSkip(1),
|
ReportTimestamp: true,
|
||||||
log.WithLevel(log.InfoLevel),
|
TimeFormat: time.Kitchen,
|
||||||
)
|
Prefix: "LoliaFRP-CLI",
|
||||||
|
CallerOffset: 1,
|
||||||
|
})
|
||||||
|
// 设置自定义样式以支持 Trace 级别
|
||||||
|
styles := log.DefaultStyles()
|
||||||
|
styles.Levels[TraceLevel] = lipgloss.NewStyle().
|
||||||
|
SetString("TRACE").
|
||||||
|
Bold(true).
|
||||||
|
MaxWidth(5).
|
||||||
|
Foreground(lipgloss.Color("61"))
|
||||||
|
Logger.SetStyles(styles)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
||||||
options := []log.Option{}
|
var output io.Writer
|
||||||
|
var err error
|
||||||
|
|
||||||
if logPath == "console" {
|
if logPath == "console" {
|
||||||
if !disableLogColor {
|
output = os.Stdout
|
||||||
options = append(options,
|
|
||||||
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
|
|
||||||
Colorful: true,
|
|
||||||
}, os.Stdout)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
writer := log.NewRotateFileWriter(log.RotateFileConfig{
|
// Use rotating file writer
|
||||||
FileName: logPath,
|
output, err = NewRotateFileWriter(logPath, maxDays)
|
||||||
Mode: log.RotateFileModeDaily,
|
if err != nil {
|
||||||
MaxDays: maxDays,
|
// Fallback to console if file creation fails
|
||||||
})
|
output = os.Stdout
|
||||||
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 = Logger.WithOptions(options...)
|
Logger = log.NewWithOptions(output, log.Options{
|
||||||
|
ReportCaller: true,
|
||||||
|
ReportTimestamp: true,
|
||||||
|
TimeFormat: time.Kitchen,
|
||||||
|
Prefix: "LoliaFRP-CLI",
|
||||||
|
CallerOffset: 1,
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRotateFileWriter creates a rotating file writer
|
||||||
|
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
|
||||||
|
w := &RotateFileWriter{
|
||||||
|
filePath: filePath,
|
||||||
|
maxDays: maxDays,
|
||||||
|
lastRotate: time.Now(),
|
||||||
|
currentDate: time.Now().Format("2006-01-02"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.openFile(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateFileWriter implements io.Writer with daily rotation
|
||||||
|
type RotateFileWriter struct {
|
||||||
|
filePath string
|
||||||
|
maxDays int
|
||||||
|
file *os.File
|
||||||
|
lastRotate time.Time
|
||||||
|
currentDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) openFile() error {
|
||||||
|
var err error
|
||||||
|
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) checkRotate() error {
|
||||||
|
now := time.Now()
|
||||||
|
currentDate := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
if currentDate != w.currentDate {
|
||||||
|
// Close current file
|
||||||
|
if w.file != nil {
|
||||||
|
w.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename current file with date suffix
|
||||||
|
oldPath := w.filePath
|
||||||
|
newPath := w.filePath + "." + w.currentDate
|
||||||
|
if _, err := os.Stat(oldPath); err == nil {
|
||||||
|
if err := os.Rename(oldPath, newPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old log files
|
||||||
|
w.cleanupOldLogs(now)
|
||||||
|
|
||||||
|
// Update current date and open new file
|
||||||
|
w.currentDate = currentDate
|
||||||
|
w.lastRotate = now
|
||||||
|
return w.openFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
|
||||||
|
if w.maxDays <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoffDate := now.AddDate(0, 0, -w.maxDays)
|
||||||
|
|
||||||
|
// Find and remove old log files
|
||||||
|
dir := filepath.Dir(w.filePath)
|
||||||
|
base := filepath.Base(w.filePath)
|
||||||
|
|
||||||
|
files, _ := os.ReadDir(dir)
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := f.Name()
|
||||||
|
// Extract date from filename (base.YYYY-MM-DD)
|
||||||
|
if dateStr, ok := strings.CutPrefix(name, base+"."); ok {
|
||||||
|
if len(dateStr) == 10 {
|
||||||
|
fileDate, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err == nil && fileDate.Before(cutoffDate) {
|
||||||
|
os.Remove(filepath.Join(dir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if err := w.checkRotate(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return w.file.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) Close() error {
|
||||||
|
if w.file != nil {
|
||||||
|
return w.file.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Errorf(format string, v ...any) {
|
func Errorf(format string, v ...any) {
|
||||||
@@ -75,6 +198,10 @@ 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...)
|
||||||
}
|
}
|
||||||
@@ -84,11 +211,12 @@ func Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Tracef(format string, v ...any) {
|
func Tracef(format string, v ...any) {
|
||||||
Logger.Tracef(format, v...)
|
Logger.Logf(TraceLevel, format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logf(level log.Level, offset int, format string, v ...any) {
|
func Logf(level log.Level, offset int, format string, v ...any) {
|
||||||
Logger.Logf(level, offset, format, v...)
|
// charmbracelet/log doesn't support offset, so we ignore it
|
||||||
|
Logger.Logf(level, format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteLogger struct {
|
type WriteLogger struct {
|
||||||
@@ -104,6 +232,8 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
||||||
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
|
// charmbracelet/log doesn't support offset in Log
|
||||||
|
msg := string(bytes.TrimRight(p, "\n"))
|
||||||
|
Logger.Log(w.level, msg)
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ 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")
|
||||||
}
|
}
|
||||||
|
|||||||
181
pkg/util/net/doh.go
Normal file
181
pkg/util/net/doh.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// Copyright 2026 The Lolia Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package net
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dohMimeType = "application/dns-message"
|
||||||
|
dohMaxResponseSize = 65535
|
||||||
|
dohRequestTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends
|
||||||
|
// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint,
|
||||||
|
// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query".
|
||||||
|
func SetDefaultDNSOverHTTPS(dohURL string) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: dohRequestTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: dohRequestTimeout,
|
||||||
|
// Use a fresh resolver to look up the DoH server hostname itself,
|
||||||
|
// avoiding infinite recursion through net.DefaultResolver.
|
||||||
|
Resolver: &net.Resolver{},
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
net.DefaultResolver = &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
return &dohConn{
|
||||||
|
ctx: ctx,
|
||||||
|
client: client,
|
||||||
|
url: dohURL,
|
||||||
|
network: network,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dohConn adapts the DNS wire-format messages exchanged by net.Resolver
|
||||||
|
// into DNS-over-HTTPS requests. Since dohConn does not implement
|
||||||
|
// net.PacketConn, the resolver always uses stream (TCP-style) framing with a
|
||||||
|
// 2-byte big-endian length prefix, regardless of the dialed network. The
|
||||||
|
// resolver writes a query and then reads the response from the same
|
||||||
|
// connection; the HTTP round trip happens synchronously inside Write.
|
||||||
|
type dohConn struct {
|
||||||
|
ctx context.Context
|
||||||
|
client *http.Client
|
||||||
|
url string
|
||||||
|
network string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
deadline time.Time
|
||||||
|
reqBuf bytes.Buffer // unprocessed request bytes
|
||||||
|
respBuf bytes.Buffer // response stream with 2-byte length prefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dohConn) Write(b []byte) (int, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.reqBuf.Write(b)
|
||||||
|
for {
|
||||||
|
data := c.reqBuf.Bytes()
|
||||||
|
if len(data) < 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
msgLen := int(binary.BigEndian.Uint16(data))
|
||||||
|
if len(data) < 2+msgLen {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
msg := make([]byte, msgLen)
|
||||||
|
copy(msg, data[2:2+msgLen])
|
||||||
|
c.reqBuf.Next(2 + msgLen)
|
||||||
|
|
||||||
|
resp, err := c.roundTrip(msg)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var lenBuf [2]byte
|
||||||
|
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp)))
|
||||||
|
c.respBuf.Write(lenBuf[:])
|
||||||
|
c.respBuf.Write(resp)
|
||||||
|
}
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dohConn) Read(b []byte) (int, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.respBuf.Len() == 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
return c.respBuf.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dohConn) roundTrip(query []byte) ([]byte, error) {
|
||||||
|
ctx := c.ctx
|
||||||
|
if !c.deadline.IsZero() {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithDeadline(ctx, c.deadline)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", dohMimeType)
|
||||||
|
req.Header.Set("Accept", dohMimeType)
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(body) > dohMaxResponseSize {
|
||||||
|
return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dohConn) Close() error { return nil }
|
||||||
|
|
||||||
|
func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} }
|
||||||
|
func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} }
|
||||||
|
|
||||||
|
func (c *dohConn) SetDeadline(t time.Time) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.deadline = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
|
||||||
|
func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) }
|
||||||
|
|
||||||
|
type dohAddr struct {
|
||||||
|
network string
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *dohAddr) Network() string { return a.network }
|
||||||
|
func (a *dohAddr) String() string { return a.addr }
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.69.0"
|
var version = "LoliaFRP-CLI 0.69.3"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -39,11 +39,15 @@ var ErrNoRouteFound = errors.New("no route found")
|
|||||||
|
|
||||||
type HTTPReverseProxyOptions struct {
|
type HTTPReverseProxyOptions struct {
|
||||||
ResponseHeaderTimeoutS int64
|
ResponseHeaderTimeoutS int64
|
||||||
|
// HTTPSRedirector, if set, redirects requests whose host has no HTTP
|
||||||
|
// route but opted into HTTP to HTTPS redirection.
|
||||||
|
HTTPSRedirector *HTTPSRedirector
|
||||||
}
|
}
|
||||||
|
|
||||||
type HTTPReverseProxy struct {
|
type HTTPReverseProxy struct {
|
||||||
proxy http.Handler
|
proxy http.Handler
|
||||||
vhostRouter *Routers
|
vhostRouter *Routers
|
||||||
|
httpsRedirector *HTTPSRedirector
|
||||||
|
|
||||||
responseHeaderTimeout time.Duration
|
responseHeaderTimeout time.Duration
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
|
|||||||
rp := &HTTPReverseProxy{
|
rp := &HTTPReverseProxy{
|
||||||
responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,
|
responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,
|
||||||
vhostRouter: vhostRouter,
|
vhostRouter: vhostRouter,
|
||||||
|
httpsRedirector: option.HTTPSRedirector,
|
||||||
}
|
}
|
||||||
proxy := &httputil.ReverseProxy{
|
proxy := &httputil.ReverseProxy{
|
||||||
// Modify incoming requests by route policies.
|
// Modify incoming requests by route policies.
|
||||||
@@ -281,6 +286,12 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hosts without any real HTTP route may still ask for a redirect to
|
||||||
|
// their HTTPS endpoint; registered HTTP routes always take precedence.
|
||||||
|
if rc == nil && rp.httpsRedirector != nil && rp.httpsRedirector.Redirect(rw, newreq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if req.Method == http.MethodConnect {
|
if req.Method == http.MethodConnect {
|
||||||
rp.connectHandler(rw, newreq)
|
rp.connectHandler(rw, newreq)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
93
pkg/util/vhost/https_redirect.go
Normal file
93
pkg/util/vhost/https_redirect.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package vhost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPSRedirector tracks domains of HTTPS proxies that opted into automatic
|
||||||
|
// HTTP to HTTPS redirection. The HTTP reverse proxy consults it as a fallback
|
||||||
|
// for hosts that have no real HTTP route, so registered HTTP proxies on the
|
||||||
|
// same domain always take precedence over the redirect.
|
||||||
|
type HTTPSRedirector struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
domains map[string]int // domain -> number of proxies requesting the redirect
|
||||||
|
port int // port used in the redirect Location, as seen by browsers
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPSRedirector(port int) *HTTPSRedirector {
|
||||||
|
return &HTTPSRedirector{
|
||||||
|
domains: make(map[string]int),
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPSRedirector) Add(domain string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.domains[domain]++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPSRedirector) Remove(domain string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.domains[domain] <= 1 {
|
||||||
|
delete(r.domains, domain)
|
||||||
|
} else {
|
||||||
|
r.domains[domain]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPSRedirector) match(host string) bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.domains[host] > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect responds with a redirect to the HTTPS endpoint of the requested
|
||||||
|
// host if the host opted into redirection. It reports whether the request
|
||||||
|
// was handled.
|
||||||
|
func (r *HTTPSRedirector) Redirect(rw http.ResponseWriter, req *http.Request) bool {
|
||||||
|
if req.Method == http.MethodConnect {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host, err := httppkg.CanonicalHost(req.Host)
|
||||||
|
if err != nil || !r.match(host) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
target := url.URL{
|
||||||
|
Scheme: "https",
|
||||||
|
Host: host,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
RawPath: req.URL.RawPath,
|
||||||
|
RawQuery: req.URL.RawQuery,
|
||||||
|
}
|
||||||
|
if r.port != 443 {
|
||||||
|
target.Host = net.JoinHostPort(host, strconv.Itoa(r.port))
|
||||||
|
}
|
||||||
|
// Not an open redirect: the host is validated against the registered
|
||||||
|
// domain set above, the scheme is fixed, and only the same-host path and
|
||||||
|
// query are echoed back.
|
||||||
|
http.Redirect(rw, req, target.String(), http.StatusMovedPermanently) //nolint:gosec // G710
|
||||||
|
return true
|
||||||
|
}
|
||||||
46
pkg/util/vhost/https_redirect_test.go
Normal file
46
pkg/util/vhost/https_redirect_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package vhost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPSRedirectorRedirect(t *testing.T) {
|
||||||
|
r := NewHTTPSRedirector(443)
|
||||||
|
r.Add("a.example.com")
|
||||||
|
|
||||||
|
// registered domain, port stripped from host, path and query preserved
|
||||||
|
req := httptest.NewRequest("GET", "http://a.example.com:8080/foo?x=1", nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
require.True(t, r.Redirect(rw, req))
|
||||||
|
require.Equal(t, 301, rw.Code)
|
||||||
|
require.Equal(t, "https://a.example.com/foo?x=1", rw.Header().Get("Location"))
|
||||||
|
|
||||||
|
// unknown domain is not handled
|
||||||
|
req = httptest.NewRequest("GET", "http://b.example.com/", nil)
|
||||||
|
require.False(t, r.Redirect(httptest.NewRecorder(), req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPSRedirectorNonDefaultPort(t *testing.T) {
|
||||||
|
r := NewHTTPSRedirector(8443)
|
||||||
|
r.Add("a.example.com")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "http://a.example.com/", nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
require.True(t, r.Redirect(rw, req))
|
||||||
|
require.Equal(t, "https://a.example.com:8443/", rw.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPSRedirectorRefCount(t *testing.T) {
|
||||||
|
r := NewHTTPSRedirector(443)
|
||||||
|
r.Add("a.example.com")
|
||||||
|
r.Add("a.example.com")
|
||||||
|
|
||||||
|
r.Remove("a.example.com")
|
||||||
|
require.True(t, r.match("a.example.com"), "domain removed while another proxy still references it")
|
||||||
|
|
||||||
|
r.Remove("a.example.com")
|
||||||
|
require.False(t, r.match("a.example.com"))
|
||||||
|
}
|
||||||
@@ -28,23 +28,70 @@ var NotFoundPagePath = ""
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
NotFound = `<!DOCTYPE html>
|
NotFound = `<!DOCTYPE html>
|
||||||
<html>
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<title>Not Found</title>
|
<meta charset="UTF-8">
|
||||||
<style>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
body {
|
<title>404 - 未绑定域名</title>
|
||||||
width: 35em;
|
<style>
|
||||||
margin: 0 auto;
|
body {
|
||||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
font-family: -apple-system, sans-serif;
|
||||||
}
|
display: flex;
|
||||||
</style>
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #666;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
text-align: left;
|
||||||
|
margin: 20px auto;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>The page you requested was not found.</h1>
|
<div class="container">
|
||||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
<h1>域名未绑定</h1>
|
||||||
Please try again later.</p>
|
<p>这个域名还没有绑定到任何隧道哦 (;д;)</p>
|
||||||
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
|
<p><strong>可能是这些原因:</strong></p>
|
||||||
<p><em>Faithfully yours, frp.</em></p>
|
<ul>
|
||||||
|
<li>域名配置不对,或者没有正确解析</li>
|
||||||
|
<li>隧道可能还没启动,或者已经停止</li>
|
||||||
|
<li>自定义域名忘记在服务端配置了</li>
|
||||||
|
</ul>
|
||||||
|
<div class="footer">由 <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
@@ -69,7 +116,7 @@ func getNotFoundPageContent() []byte {
|
|||||||
|
|
||||||
func NotFoundResponse() *http.Response {
|
func NotFoundResponse() *http.Response {
|
||||||
header := make(http.Header)
|
header := make(http.Header)
|
||||||
header.Set("server", "frp/"+version.Full())
|
header.Set("server", 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.Tracef(l.prefixString+format, v...)
|
log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,18 +36,25 @@ 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)
|
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
|
||||||
|
|
||||||
// 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")
|
||||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||||
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/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||||
|
subRouter.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/{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")
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
|
|||||||
@@ -92,6 +92,51 @@ func (cm *ControlManager) Close() error {
|
|||||||
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
|
||||||
@@ -112,6 +157,8 @@ type SessionContext struct {
|
|||||||
ServerCfg *v1.ServerConfig
|
ServerCfg *v1.ServerConfig
|
||||||
// client registry
|
// client registry
|
||||||
ClientRegistry *registry.ClientRegistry
|
ClientRegistry *registry.ClientRegistry
|
||||||
|
// negotiated wire protocol for this client session
|
||||||
|
WireProtocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Control struct {
|
type Control struct {
|
||||||
@@ -452,6 +499,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
|
|||||||
Configurer: pxyConf,
|
Configurer: pxyConf,
|
||||||
ServerCfg: ctl.sessionCtx.ServerCfg,
|
ServerCfg: ctl.sessionCtx.ServerCfg,
|
||||||
EncryptionKey: ctl.sessionCtx.EncryptionKey,
|
EncryptionKey: ctl.sessionCtx.EncryptionKey,
|
||||||
|
WireProtocol: ctl.sessionCtx.WireProtocol,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return remoteAddr, err
|
return remoteAddr, err
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type ResourceController struct {
|
|||||||
// For HTTP proxies, forwarding HTTP requests
|
// For HTTP proxies, forwarding HTTP requests
|
||||||
HTTPReverseProxy *vhost.HTTPReverseProxy
|
HTTPReverseProxy *vhost.HTTPReverseProxy
|
||||||
|
|
||||||
|
// Domains of HTTPS proxies that requested automatic HTTP to HTTPS redirection
|
||||||
|
HTTPSRedirector *vhost.HTTPSRedirector
|
||||||
|
|
||||||
// For HTTPS proxies, route requests to different clients by hostname and other information
|
// For HTTPS proxies, route requests to different clients by hostname and other information
|
||||||
VhostHTTPSMuxer *vhost.HTTPSMuxer
|
VhostHTTPSMuxer *vhost.HTTPSMuxer
|
||||||
|
|
||||||
|
|||||||
@@ -38,21 +38,28 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +225,24 @@ 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")
|
||||||
|
|||||||
394
server/http/controller_v2.go
Normal file
394
server/http/controller_v2.go
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
// 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 (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultV2Page = 1
|
||||||
|
defaultV2PageSize = 50
|
||||||
|
maxV2PageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
var apiV2ProxyTypes = []string{
|
||||||
|
string(v1.ProxyTypeTCP),
|
||||||
|
string(v1.ProxyTypeUDP),
|
||||||
|
string(v1.ProxyTypeHTTP),
|
||||||
|
string(v1.ProxyTypeHTTPS),
|
||||||
|
string(v1.ProxyTypeTCPMUX),
|
||||||
|
string(v1.ProxyTypeSTCP),
|
||||||
|
string(v1.ProxyTypeXTCP),
|
||||||
|
string(v1.ProxyTypeSUDP),
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/users
|
||||||
|
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||||
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
userStats := make(map[string]*model.V2UserResp)
|
||||||
|
for _, info := range c.clientRegistry.List() {
|
||||||
|
item := getOrCreateV2User(userStats, info.User)
|
||||||
|
item.ClientCount++
|
||||||
|
}
|
||||||
|
for _, proxyInfo := range c.listV2ProxyStats("") {
|
||||||
|
item := getOrCreateV2User(userStats, proxyInfo.User)
|
||||||
|
item.ProxyCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
q := strings.ToLower(ctx.Query("q"))
|
||||||
|
items := make([]model.V2UserResp, 0, len(userStats))
|
||||||
|
for _, item := range userStats {
|
||||||
|
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
slices.SortFunc(items, func(a, b model.V2UserResp) int {
|
||||||
|
return cmp.Compare(a.User, b.User)
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildV2PageResp(items, page, pageSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/clients
|
||||||
|
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||||
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userFilter, filterByUser := queryValue(ctx, "user")
|
||||||
|
clientIDFilter := ctx.Query("clientID")
|
||||||
|
runIDFilter := ctx.Query("runID")
|
||||||
|
q := strings.ToLower(ctx.Query("q"))
|
||||||
|
|
||||||
|
records := c.clientRegistry.List()
|
||||||
|
items := make([]model.ClientInfoResp, 0, len(records))
|
||||||
|
for _, info := range records {
|
||||||
|
if filterByUser && info.User != userFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runIDFilter != "" && info.RunID != runIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchV2StatusFilter(info.Online, statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp := buildClientInfoResp(info)
|
||||||
|
if q != "" && !matchV2ClientQuery(resp, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
|
||||||
|
if v := cmp.Compare(a.User, b.User); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildV2PageResp(items, page, pageSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/clients/{key}
|
||||||
|
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||||
|
return c.APIClientDetail(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/proxies
|
||||||
|
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||||
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userFilter, filterByUser := queryValue(ctx, "user")
|
||||||
|
clientIDFilter := ctx.Query("clientID")
|
||||||
|
q := strings.ToLower(ctx.Query("q"))
|
||||||
|
|
||||||
|
stats := c.listV2ProxyStats(proxyType)
|
||||||
|
items := make([]model.V2ProxyResp, 0, len(stats))
|
||||||
|
for _, ps := range stats {
|
||||||
|
resp := c.buildV2ProxyResp(ps)
|
||||||
|
if filterByUser && resp.User != userFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q != "" && !matchV2ProxyQuery(resp, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||||
|
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildV2PageResp(items, page, pageSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/proxies/{name}
|
||||||
|
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("missing proxy name")
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := mem.StatsCollector.GetProxyByName(name)
|
||||||
|
if ps == nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||||
|
}
|
||||||
|
return c.buildV2ProxyResp(ps), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||||
|
item, ok := items[user]
|
||||||
|
if !ok {
|
||||||
|
item = &model.V2UserResp{User: user}
|
||||||
|
items[user] = item
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
|
||||||
|
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if pageSize > maxV2PageSize {
|
||||||
|
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
|
||||||
|
}
|
||||||
|
if page > math.MaxInt/pageSize {
|
||||||
|
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
|
||||||
|
}
|
||||||
|
return page, pageSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < 1 {
|
||||||
|
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2StatusFilter(raw string) (string, error) {
|
||||||
|
status := strings.ToLower(raw)
|
||||||
|
switch status {
|
||||||
|
case "", "all", "online", "offline":
|
||||||
|
return status, nil
|
||||||
|
default:
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||||
|
proxyType := strings.ToLower(raw)
|
||||||
|
if proxyType == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if slices.Contains(apiV2ProxyTypes, proxyType) {
|
||||||
|
return proxyType, nil
|
||||||
|
}
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchV2StatusFilter(online bool, filter string) bool {
|
||||||
|
switch filter {
|
||||||
|
case "", "all":
|
||||||
|
return true
|
||||||
|
case "online":
|
||||||
|
return online
|
||||||
|
case "offline":
|
||||||
|
return !online
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
|
||||||
|
total := len(items)
|
||||||
|
return model.V2PageResp[T]{
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Items: paginateV2Items(items, page, pageSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginateV2Items[T any](items []T, page, pageSize int) []T {
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start >= len(items) {
|
||||||
|
return []T{}
|
||||||
|
}
|
||||||
|
end := min(start+pageSize, len(items))
|
||||||
|
return items[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
|
||||||
|
values, ok := ctx.Req.URL.Query()[key]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
return values[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
||||||
|
return containsV2Query(q,
|
||||||
|
item.Key,
|
||||||
|
item.User,
|
||||||
|
item.ClientID,
|
||||||
|
item.RunID,
|
||||||
|
item.Version,
|
||||||
|
item.WireProtocol,
|
||||||
|
item.Hostname,
|
||||||
|
item.ClientIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||||
|
values := []string{
|
||||||
|
item.Name,
|
||||||
|
item.Type,
|
||||||
|
item.User,
|
||||||
|
item.ClientID,
|
||||||
|
item.Status.State,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch spec := item.Spec.(type) {
|
||||||
|
case *model.TCPOutConf:
|
||||||
|
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||||
|
case *model.UDPOutConf:
|
||||||
|
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||||
|
case *model.HTTPOutConf:
|
||||||
|
values = append(values, spec.CustomDomains...)
|
||||||
|
values = append(values, spec.SubDomain)
|
||||||
|
case *model.HTTPSOutConf:
|
||||||
|
values = append(values, spec.CustomDomains...)
|
||||||
|
values = append(values, spec.SubDomain)
|
||||||
|
case *model.TCPMuxOutConf:
|
||||||
|
values = append(values, spec.CustomDomains...)
|
||||||
|
values = append(values, spec.SubDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return containsV2Query(q, values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsV2Query(q string, values ...string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.Contains(strings.ToLower(value), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||||
|
if proxyType != "" {
|
||||||
|
return mem.StatsCollector.GetProxiesByType(proxyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*mem.ProxyStats, 0)
|
||||||
|
for _, t := range apiV2ProxyTypes {
|
||||||
|
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||||
|
state := "offline"
|
||||||
|
var spec any
|
||||||
|
if c.pxyManager != nil {
|
||||||
|
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||||
|
state = "online"
|
||||||
|
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2ProxyResp{
|
||||||
|
Name: ps.Name,
|
||||||
|
Type: ps.Type,
|
||||||
|
User: ps.User,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Spec: spec,
|
||||||
|
Status: model.V2ProxyStatusResp{
|
||||||
|
State: state,
|
||||||
|
TodayTrafficIn: ps.TodayTrafficIn,
|
||||||
|
TodayTrafficOut: ps.TodayTrafficOut,
|
||||||
|
CurConns: ps.CurConns,
|
||||||
|
LastStartTime: ps.LastStartTime,
|
||||||
|
LastCloseTime: ps.LastCloseTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
374
server/http/controller_v2_test.go
Normal file
374
server/http/controller_v2_test.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
serverproxy "github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type v2EnvelopeForTest[T any] struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStatsCollector struct {
|
||||||
|
proxies map[string]*mem.ProxyStats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||||
|
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
|
||||||
|
items := make([]*mem.ProxyStats, 0)
|
||||||
|
for _, ps := range f.proxies {
|
||||||
|
if ps.Type == proxyType {
|
||||||
|
items = append(items, ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
|
||||||
|
ps := f.proxies[proxyName]
|
||||||
|
if ps != nil && ps.Type == proxyType {
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||||
|
return f.proxies[proxyName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||||
|
return 0, len(f.proxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", pageResp)
|
||||||
|
}
|
||||||
|
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
|
||||||
|
t.Fatalf("page data mismatch: %#v", pageResp.Data)
|
||||||
|
}
|
||||||
|
if got := pageResp.Data.Items[0].User; got != "" {
|
||||||
|
t.Fatalf("first sorted user mismatch, want empty got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
|
||||||
|
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
|
||||||
|
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
|
||||||
|
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
|
||||||
|
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?status=offline")
|
||||||
|
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
|
||||||
|
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/clients?page=0")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
||||||
|
t.Fatalf("error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?pageSize=201")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/clients/alice.client-a")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
|
||||||
|
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
||||||
|
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies?type=invalid")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
||||||
|
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
|
||||||
|
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
|
||||||
|
if proxyResp.Data.Total != 1 {
|
||||||
|
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||||
|
}
|
||||||
|
proxyItem := proxyResp.Data.Items[0]
|
||||||
|
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||||
|
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||||
|
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||||
|
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
||||||
|
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||||
|
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||||
|
if userResp.Data.Total != 3 {
|
||||||
|
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||||
|
}
|
||||||
|
for _, item := range userResp.Data.Items {
|
||||||
|
if item.ClientCount != 1 || item.ProxyCount != 1 {
|
||||||
|
t.Fatalf("user counts mismatch: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
item model.V2ProxyResp
|
||||||
|
q string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tcp remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||||
|
RemotePort: 6000,
|
||||||
|
}},
|
||||||
|
q: "6000",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "udp remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
||||||
|
RemotePort: 7000,
|
||||||
|
}},
|
||||||
|
q: "7000",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote port does not match colon form",
|
||||||
|
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||||
|
RemotePort: 6000,
|
||||||
|
}},
|
||||||
|
q: ":6000",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http custom domain",
|
||||||
|
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
||||||
|
}},
|
||||||
|
q: "app.example.com",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https subdomain",
|
||||||
|
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||||
|
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||||
|
}},
|
||||||
|
q: "portal",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain does not match expanded host",
|
||||||
|
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||||
|
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||||
|
}},
|
||||||
|
q: "portal.example.com",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcpmux custom domain",
|
||||||
|
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
||||||
|
}},
|
||||||
|
q: "mux.example.com",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil spec does not match spec fields",
|
||||||
|
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||||
|
q: "6000",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
|
||||||
|
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/clients")
|
||||||
|
var clients []model.ClientInfoResp
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
if len(clients) != 3 {
|
||||||
|
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/proxy/tcp")
|
||||||
|
var proxies model.GetProxyInfoResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
|
||||||
|
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(proxies.Proxies) != 2 {
|
||||||
|
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
|
||||||
|
}
|
||||||
|
var envelope httppkg.V2Response
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2TestController(t *testing.T) *Controller {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"tcp-empty": {
|
||||||
|
Name: "tcp-empty",
|
||||||
|
Type: "tcp",
|
||||||
|
User: "",
|
||||||
|
ClientID: "legacy-client",
|
||||||
|
TodayTrafficIn: 10,
|
||||||
|
TodayTrafficOut: 20,
|
||||||
|
CurConns: 1,
|
||||||
|
},
|
||||||
|
"tcp-alice": {
|
||||||
|
Name: "tcp-alice",
|
||||||
|
Type: "tcp",
|
||||||
|
User: "alice",
|
||||||
|
ClientID: "client-a",
|
||||||
|
TodayTrafficIn: 30,
|
||||||
|
TodayTrafficOut: 40,
|
||||||
|
},
|
||||||
|
"udp-bob": {
|
||||||
|
Name: "udp-bob",
|
||||||
|
Type: "udp",
|
||||||
|
User: "bob",
|
||||||
|
ClientID: "client-b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
|
||||||
|
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
|
||||||
|
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
||||||
|
clientRegistry.MarkOfflineByRunID("run-b")
|
||||||
|
|
||||||
|
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).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)
|
||||||
|
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)
|
||||||
|
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
46
server/http/model/v2.go
Normal file
46
server/http/model/v2.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// 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 model
|
||||||
|
|
||||||
|
type V2PageResp[T any] struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Items []T `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2UserResp struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
ClientCount int `json:"clientCount"`
|
||||||
|
ProxyCount int `json:"proxyCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyResp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
User string `json:"user"`
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
Spec any `json:"spec"`
|
||||||
|
Status V2ProxyStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyStatusResp struct {
|
||||||
|
State string `json:"phase"`
|
||||||
|
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||||
|
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
LastStartTime string `json:"lastStartTime"`
|
||||||
|
LastCloseTime string `json:"lastCloseTime"`
|
||||||
|
}
|
||||||
@@ -144,18 +144,26 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
name := pxy.GetName()
|
||||||
|
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
||||||
|
rwc = wrapCountingReadWriteCloser(rwc, func(bytes int64) {
|
||||||
|
metrics.Server.AddTrafficOut(name, proxyType, bytes)
|
||||||
|
}, func(bytes int64) {
|
||||||
|
metrics.Server.AddTrafficIn(name, proxyType, bytes)
|
||||||
|
})
|
||||||
|
|
||||||
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
||||||
workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)
|
workConn = netpkg.WrapCloseNotifyConn(workConn, func(error) {
|
||||||
metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
pxy.updateStatsAfterClosedConn()
|
||||||
|
})
|
||||||
|
metrics.Server.OpenConnection(name, proxyType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {
|
func (pxy *HTTPProxy) updateStatsAfterClosedConn() {
|
||||||
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() {
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func init() {
|
|||||||
type HTTPSProxy struct {
|
type HTTPSProxy struct {
|
||||||
*BaseProxy
|
*BaseProxy
|
||||||
cfg *v1.HTTPSProxyConfig
|
cfg *v1.HTTPSProxyConfig
|
||||||
|
|
||||||
|
// domains registered for HTTP to HTTPS redirection, removed on Close
|
||||||
|
redirectDomains []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPSProxy(baseProxy *BaseProxy) Proxy {
|
func NewHTTPSProxy(baseProxy *BaseProxy) Proxy {
|
||||||
@@ -66,12 +69,28 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
|
|||||||
xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
|
xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pxy.cfg.HTTPRedirect {
|
||||||
|
if pxy.rc.HTTPSRedirector != nil {
|
||||||
|
for _, domain := range domains {
|
||||||
|
pxy.rc.HTTPSRedirector.Add(domain)
|
||||||
|
}
|
||||||
|
pxy.redirectDomains = domains
|
||||||
|
xl.Infof("https proxy enabled http redirect for hosts %v", domains)
|
||||||
|
} else {
|
||||||
|
xl.Warnf("httpRedirect is enabled but frps has no vhostHTTPPort or vhostHTTPSPort, ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pxy.startCommonTCPListenersHandler()
|
pxy.startCommonTCPListenersHandler()
|
||||||
remoteAddr = strings.Join(addrs, ",")
|
remoteAddr = strings.Join(addrs, ",")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *HTTPSProxy) Close() {
|
func (pxy *HTTPSProxy) Close() {
|
||||||
|
for _, domain := range pxy.redirectDomains {
|
||||||
|
pxy.rc.HTTPSRedirector.Remove(domain)
|
||||||
|
}
|
||||||
|
pxy.redirectDomains = nil
|
||||||
pxy.BaseProxy.Close()
|
pxy.BaseProxy.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
server/proxy/idle_watcher.go
Normal file
47
server/proxy/idle_watcher.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// idleWatcher closes a proxied connection pair when no bytes have flowed in
|
||||||
|
// either direction for the configured timeout. Without it, endpoints that
|
||||||
|
// stay open at the TCP level but never send data again would pin the join
|
||||||
|
// goroutines and their transfer buffers forever.
|
||||||
|
type idleWatcher struct {
|
||||||
|
lastActive atomic.Int64 // unix nano of the last byte transferred
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startIdleWatcher(timeout time.Duration, closeFn func()) *idleWatcher {
|
||||||
|
w := &idleWatcher{stopCh: make(chan struct{})}
|
||||||
|
w.touch()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-w.stopCh:
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
idle := time.Since(time.Unix(0, w.lastActive.Load()))
|
||||||
|
if idle >= timeout {
|
||||||
|
closeFn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.Reset(timeout - idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *idleWatcher) touch() {
|
||||||
|
w.lastActive.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *idleWatcher) stop() {
|
||||||
|
close(w.stopCh)
|
||||||
|
}
|
||||||
52
server/proxy/idle_watcher_test.go
Normal file
52
server/proxy/idle_watcher_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIdleWatcherClosesAfterTimeout(t *testing.T) {
|
||||||
|
closed := make(chan struct{})
|
||||||
|
w := startIdleWatcher(50*time.Millisecond, func() {
|
||||||
|
close(closed)
|
||||||
|
})
|
||||||
|
defer w.stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("closeFn was not called after idle timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdleWatcherTouchPostponesClose(t *testing.T) {
|
||||||
|
var closedFlag atomic.Bool
|
||||||
|
w := startIdleWatcher(100*time.Millisecond, func() {
|
||||||
|
closedFlag.Store(true)
|
||||||
|
})
|
||||||
|
defer w.stop()
|
||||||
|
|
||||||
|
// keep the connection "active" well past the timeout
|
||||||
|
for range 6 {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
w.touch()
|
||||||
|
}
|
||||||
|
require.False(t, closedFlag.Load(), "closeFn fired despite ongoing activity")
|
||||||
|
|
||||||
|
// once activity stops, it should still close
|
||||||
|
require.Eventually(t, closedFlag.Load, time.Second, 10*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdleWatcherStopPreventsClose(t *testing.T) {
|
||||||
|
var closedFlag atomic.Bool
|
||||||
|
w := startIdleWatcher(50*time.Millisecond, func() {
|
||||||
|
closedFlag.Store(true)
|
||||||
|
})
|
||||||
|
w.stop()
|
||||||
|
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
require.False(t, closedFlag.Load(), "closeFn fired after watcher was stopped")
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
"github.com/fatedier/frp/pkg/util/limit"
|
"github.com/fatedier/frp/pkg/util/limit"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
@@ -92,6 +94,7 @@ type BaseProxy struct {
|
|||||||
userInfo plugin.UserInfo
|
userInfo plugin.UserInfo
|
||||||
loginMsg *msg.Login
|
loginMsg *msg.Login
|
||||||
configurer v1.ProxyConfigurer
|
configurer v1.ProxyConfigurer
|
||||||
|
wireProtocol string
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
@@ -314,14 +317,175 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
|||||||
|
|
||||||
name := pxy.GetName()
|
name := pxy.GetName()
|
||||||
proxyType := cfg.Type
|
proxyType := cfg.Type
|
||||||
|
var watcher *idleWatcher
|
||||||
|
touch := func() {
|
||||||
|
if watcher != nil {
|
||||||
|
watcher.touch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local = wrapCountingReadWriteCloser(local, nil, func(bytes int64) {
|
||||||
|
touch()
|
||||||
|
metrics.Server.AddTrafficIn(name, proxyType, bytes)
|
||||||
|
})
|
||||||
|
userConn = netpkg.WrapReadWriteCloserToConn(
|
||||||
|
wrapCountingReadWriteCloser(userConn, nil, func(bytes int64) {
|
||||||
|
touch()
|
||||||
|
metrics.Server.AddTrafficOut(name, proxyType, bytes)
|
||||||
|
}),
|
||||||
|
userConn,
|
||||||
|
)
|
||||||
|
if idleTimeout := time.Duration(pxy.serverCfg.Transport.ProxyIdleTimeout) * time.Second; idleTimeout > 0 {
|
||||||
|
wrappedLocal, wrappedUserConn := local, userConn
|
||||||
|
watcher = startIdleWatcher(idleTimeout, func() {
|
||||||
|
xl.Infof("close user connection [%s] of proxy [%s]: no traffic in either direction for %s",
|
||||||
|
wrappedUserConn.RemoteAddr().String(), name, idleTimeout)
|
||||||
|
_ = wrappedUserConn.Close()
|
||||||
|
_ = wrappedLocal.Close()
|
||||||
|
})
|
||||||
|
defer watcher.stop()
|
||||||
|
}
|
||||||
metrics.Server.OpenConnection(name, proxyType)
|
metrics.Server.OpenConnection(name, proxyType)
|
||||||
inCount, outCount, _ := libio.Join(local, userConn)
|
// Traffic is counted incrementally via the counting wrappers above, so the
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) {
|
||||||
|
visitorWireProtocol := wireProtocolFromConn(userConn)
|
||||||
|
if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) {
|
||||||
|
xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]",
|
||||||
|
normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol))
|
||||||
|
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl)
|
||||||
|
}
|
||||||
|
return libio.Join(local, userConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireProtocolGetter interface {
|
||||||
|
WireProtocol() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func wireProtocolFromConn(conn net.Conn) string {
|
||||||
|
if getter, ok := conn.(wireProtocolGetter); ok {
|
||||||
|
return getter.WireProtocol()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMixedWireProtocol(left, right string) bool {
|
||||||
|
return normalizeWireProtocol(left) != normalizeWireProtocol(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWireProtocol(wireProtocol string) string {
|
||||||
|
if wireProtocol == wire.ProtocolV2 {
|
||||||
|
return wire.ProtocolV2
|
||||||
|
}
|
||||||
|
return wire.ProtocolV1
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinSUDPMessageBridge(
|
||||||
|
proxyConn io.ReadWriteCloser,
|
||||||
|
visitorConn io.ReadWriteCloser,
|
||||||
|
proxyWireProtocol string,
|
||||||
|
visitorWireProtocol string,
|
||||||
|
xl *xlog.Logger,
|
||||||
|
) (inCount int64, outCount int64, errs []error) {
|
||||||
|
// The mixed bridge decodes and re-encodes messages, so raw framed byte counts
|
||||||
|
// are not available. Count UDP payload bytes and ignore heartbeat traffic.
|
||||||
|
proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol)
|
||||||
|
visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol)
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
wait sync.WaitGroup
|
||||||
|
recordErrs = make([]error, 2)
|
||||||
|
)
|
||||||
|
closeBoth := func() {
|
||||||
|
_ = proxyConn.Close()
|
||||||
|
_ = visitorConn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
wait.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
defer once.Do(closeBoth)
|
||||||
|
recordErrs[0] = bridgeSUDPProxyToVisitor(proxyRW, visitorRW, &outCount, xl)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
defer once.Do(closeBoth)
|
||||||
|
recordErrs[1] = bridgeSUDPVisitorToProxy(visitorRW, proxyRW, &inCount, xl)
|
||||||
|
}()
|
||||||
|
wait.Wait()
|
||||||
|
|
||||||
|
for _, err := range recordErrs {
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeSUDPProxyToVisitor(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
|
||||||
|
for {
|
||||||
|
rawMsg, err := from.ReadMsg()
|
||||||
|
if err != nil {
|
||||||
|
return normalizeSUDPBridgeError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m := rawMsg.(type) {
|
||||||
|
case *msg.UDPPacket:
|
||||||
|
if err := to.WriteMsg(m); err != nil {
|
||||||
|
return normalizeSUDPBridgeError(err)
|
||||||
|
}
|
||||||
|
*count += int64(len(m.Content))
|
||||||
|
case *msg.Ping:
|
||||||
|
traceSUDPBridge(xl, "bridge SUDP ping from proxy to visitor")
|
||||||
|
if err := to.WriteMsg(m); err != nil {
|
||||||
|
return normalizeSUDPBridgeError(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected SUDP proxy message %T", rawMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeSUDPVisitorToProxy(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
|
||||||
|
for {
|
||||||
|
rawMsg, err := from.ReadMsg()
|
||||||
|
if err != nil {
|
||||||
|
return normalizeSUDPBridgeError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m := rawMsg.(type) {
|
||||||
|
case *msg.UDPPacket:
|
||||||
|
if err := to.WriteMsg(m); err != nil {
|
||||||
|
return normalizeSUDPBridgeError(err)
|
||||||
|
}
|
||||||
|
*count += int64(len(m.Content))
|
||||||
|
case *msg.Ping:
|
||||||
|
traceSUDPBridge(xl, "drop SUDP ping from visitor to proxy")
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected SUDP visitor message %T", rawMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSUDPBridgeError(err error) error {
|
||||||
|
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceSUDPBridge(xl *xlog.Logger, format string, args ...any) {
|
||||||
|
if xl != nil {
|
||||||
|
xl.Tracef(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
UserInfo plugin.UserInfo
|
UserInfo plugin.UserInfo
|
||||||
LoginMsg *msg.Login
|
LoginMsg *msg.Login
|
||||||
@@ -331,6 +495,7 @@ type Options struct {
|
|||||||
Configurer v1.ProxyConfigurer
|
Configurer v1.ProxyConfigurer
|
||||||
ServerCfg *v1.ServerConfig
|
ServerCfg *v1.ServerConfig
|
||||||
EncryptionKey []byte
|
EncryptionKey []byte
|
||||||
|
WireProtocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
||||||
@@ -357,6 +522,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
|||||||
userInfo: options.UserInfo,
|
userInfo: options.UserInfo,
|
||||||
loginMsg: options.LoginMsg,
|
loginMsg: options.LoginMsg,
|
||||||
configurer: configurer,
|
configurer: configurer,
|
||||||
|
wireProtocol: options.WireProtocol,
|
||||||
}
|
}
|
||||||
|
|
||||||
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
|
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
|
||||||
|
|||||||
@@ -15,12 +15,15 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
||||||
@@ -51,3 +54,56 @@ func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
|||||||
require.NoError(t, result.err)
|
require.NoError(t, result.err)
|
||||||
require.Same(t, serverMsgConn, result.conn)
|
require.Same(t, serverMsgConn, result.conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetWorkConnFromPoolStartWorkConnUnchangedForUDPWireV2(t *testing.T) {
|
||||||
|
startMsg := getStartWorkConnFromPool(t, &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "udp", Type: string(v1.ProxyTypeUDP)},
|
||||||
|
}, wire.ProtocolV2)
|
||||||
|
|
||||||
|
require.Equal(t, msg.StartWorkConn{ProxyName: "udp"}, startMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWorkConnFromPoolLeavesRawTCPPayloadUnframed(t *testing.T) {
|
||||||
|
startMsg := getStartWorkConnFromPool(t, &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "tcp", Type: string(v1.ProxyTypeTCP)},
|
||||||
|
}, wire.ProtocolV2)
|
||||||
|
|
||||||
|
require.Equal(t, msg.StartWorkConn{ProxyName: "tcp"}, startMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStartWorkConnFromPool(t *testing.T, cfg v1.ProxyConfigurer, wireProtocol string) msg.StartWorkConn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client, server := net.Pipe()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
client.Close()
|
||||||
|
server.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
serverMsgConn := msg.NewConn(server, msg.NewV2ReadWriter(server))
|
||||||
|
clientMsgConn := msg.NewConn(client, msg.NewV2ReadWriter(client))
|
||||||
|
pxy := &BaseProxy{
|
||||||
|
name: cfg.GetBaseConfig().Name,
|
||||||
|
configurer: cfg,
|
||||||
|
poolCount: 0,
|
||||||
|
ctx: context.Background(),
|
||||||
|
wireProtocol: wireProtocol,
|
||||||
|
getWorkConnFn: func() (*WorkConn, error) {
|
||||||
|
return NewWorkConn(serverMsgConn), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
conn, err := pxy.GetWorkConnFromPool(nil, nil)
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
var startMsg msg.StartWorkConn
|
||||||
|
require.NoError(t, clientMsgConn.ReadMsgInto(&startMsg))
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
return startMsg
|
||||||
|
}
|
||||||
|
|||||||
141
server/proxy/sudp_test.go
Normal file
141
server/proxy/sudp_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// 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 proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPProxyToVisitor(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV1),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV2),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(len("proxy-to-visitor")), count)
|
||||||
|
|
||||||
|
frame, err := wire.NewConn(&out).ReadFrame()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||||
|
require.GreaterOrEqual(t, len(frame.Payload), 2)
|
||||||
|
require.Equal(t, msg.V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||||
|
|
||||||
|
var got msg.UDPPacket
|
||||||
|
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &got))
|
||||||
|
require.Equal(t, []byte("proxy-to-visitor"), got.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPVisitorToProxy(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(len("visitor-to-proxy")), count)
|
||||||
|
|
||||||
|
reader := bufio.NewReader(&out)
|
||||||
|
typeByte, err := reader.ReadByte()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, msg.TypeUDPPacket, typeByte)
|
||||||
|
require.NoError(t, reader.UnreadByte())
|
||||||
|
|
||||||
|
var got msg.UDPPacket
|
||||||
|
require.NoError(t, msg.ReadMsgInto(reader, &got))
|
||||||
|
require.Equal(t, []byte("visitor-to-proxy"), got.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPProxyToVisitor(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV1),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV2),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, count)
|
||||||
|
|
||||||
|
rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &msg.Ping{}, rawMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPVisitorToProxy(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, count)
|
||||||
|
require.Empty(t, out.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPVisitorToProxy(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.ErrorContains(t, err, "unexpected SUDP visitor message *msg.Pong")
|
||||||
|
require.Zero(t, count)
|
||||||
|
require.Empty(t, out.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
|
||||||
|
require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
|
||||||
|
require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
|
||||||
|
require.True(t, isMixedWireProtocol("", wire.ProtocolV2))
|
||||||
|
require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
|
||||||
|
}
|
||||||
36
server/proxy/traffic_counter.go
Normal file
36
server/proxy/traffic_counter.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
pxy.checkCloseCh = make(chan int)
|
pxy.checkCloseCh = make(chan int)
|
||||||
|
|
||||||
// read message from workConn, if it returns any error, notify proxy to start a new workConn
|
// read message from workConn, if it returns any error, notify proxy to start a new workConn
|
||||||
workConnReaderFn := func(conn net.Conn) {
|
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||||
for {
|
for {
|
||||||
var (
|
var (
|
||||||
rawMsg msg.Message
|
rawMsg msg.Message
|
||||||
@@ -116,10 +116,10 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
)
|
)
|
||||||
xl.Tracef("loop waiting message from udp workConn")
|
xl.Tracef("loop waiting message from udp workConn")
|
||||||
// client will send heartbeat in workConn for keeping alive
|
// client will send heartbeat in workConn for keeping alive
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
_ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
||||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||||
_ = conn.Close()
|
_ = payloadConn.Close()
|
||||||
// notify proxy to start a new work connection
|
// notify proxy to start a new work connection
|
||||||
// ignore error here, it means the proxy is closed
|
// ignore error here, it means the proxy is closed
|
||||||
_ = errors.PanicToError(func() {
|
_ = errors.PanicToError(func() {
|
||||||
@@ -127,7 +127,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
if err := payloadConn.SetReadDeadline(time.Time{}); err != nil {
|
||||||
xl.Warnf("set read deadline error: %v", err)
|
xl.Warnf("set read deadline error: %v", err)
|
||||||
}
|
}
|
||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
@@ -144,7 +144,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
int64(len(m.Content)),
|
int64(len(m.Content)),
|
||||||
)
|
)
|
||||||
}); errRet != nil {
|
}); errRet != nil {
|
||||||
conn.Close()
|
_ = payloadConn.Close()
|
||||||
xl.Infof("reader goroutine for udp work connection closed")
|
xl.Infof("reader goroutine for udp work connection closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// send message to workConn
|
// send message to workConn
|
||||||
workConnSenderFn := func(conn net.Conn, ctx context.Context) {
|
workConnSenderFn := func(payloadConn *msg.Conn, ctx context.Context) {
|
||||||
var errRet error
|
var errRet error
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -162,9 +162,9 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
xl.Infof("sender goroutine for udp work connection closed")
|
xl.Infof("sender goroutine for udp work connection closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||||
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
|
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
conn.Close()
|
_ = payloadConn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
|
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
|
||||||
@@ -223,9 +223,11 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
||||||
|
// Plain UDP payload follows the negotiated wire protocol for message framing.
|
||||||
|
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
go workConnReaderFn(pxy.workConn)
|
go workConnReaderFn(payloadConn)
|
||||||
go workConnSenderFn(pxy.workConn, ctx)
|
go workConnSenderFn(payloadConn, ctx)
|
||||||
_, ok := <-pxy.checkCloseCh
|
_, ok := <-pxy.checkCloseCh
|
||||||
cancel()
|
cancel()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -73,10 +74,7 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
|||||||
if errRet != nil {
|
if errRet != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m := &msg.NatHoleSid{
|
errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
|
||||||
Sid: sid,
|
|
||||||
}
|
|
||||||
errRet = msg.WriteMsg(workConn, m)
|
|
||||||
if errRet != nil {
|
if errRet != nil {
|
||||||
xl.Warnf("write nat hole sid package error, %v", errRet)
|
xl.Warnf("write nat hole sid package error, %v", errRet)
|
||||||
}
|
}
|
||||||
@@ -87,6 +85,13 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeNatHoleSid(workConn net.Conn, wireProtocol string, sid string) error {
|
||||||
|
workMsgConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, wireProtocol))
|
||||||
|
return workMsgConn.WriteMsg(&msg.NatHoleSid{
|
||||||
|
Sid: sid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (pxy *XTCPProxy) Close() {
|
func (pxy *XTCPProxy) Close() {
|
||||||
pxy.closeOnce.Do(func() {
|
pxy.closeOnce.Do(func() {
|
||||||
pxy.BaseProxy.Close()
|
pxy.BaseProxy.Close()
|
||||||
|
|||||||
93
server/proxy/xtcp_test.go
Normal file
93
server/proxy/xtcp_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteNatHoleSidUsesWireV2MessageFrame(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
setPipeDeadline(t, client, server)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- writeNatHoleSid(server, wire.ProtocolV2, "sid-v2")
|
||||||
|
}()
|
||||||
|
|
||||||
|
frame, err := wire.NewConn(client).ReadFrame()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||||
|
require.GreaterOrEqual(t, len(frame.Payload), 2)
|
||||||
|
require.Equal(t, msg.V2TypeNatHoleSid, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||||
|
|
||||||
|
var out msg.NatHoleSid
|
||||||
|
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &out))
|
||||||
|
require.Equal(t, "sid-v2", out.Sid)
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNatHoleSidUsesLegacyCodecForWireV1AndDefault(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
wireProtocol string
|
||||||
|
}{
|
||||||
|
{name: "default", wireProtocol: ""},
|
||||||
|
{name: "v1", wireProtocol: wire.ProtocolV1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
setPipeDeadline(t, client, server)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- writeNatHoleSid(server, tc.wireProtocol, "sid-legacy")
|
||||||
|
}()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(client)
|
||||||
|
typeByte, err := reader.ReadByte()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, msg.TypeNatHoleSid, typeByte)
|
||||||
|
require.NoError(t, reader.UnreadByte())
|
||||||
|
|
||||||
|
var out msg.NatHoleSid
|
||||||
|
require.NoError(t, msg.ReadMsgInto(reader, &out))
|
||||||
|
require.Equal(t, "sid-legacy", out.Sid)
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for _, conn := range conns {
|
||||||
|
require.NoError(t, conn.SetDeadline(deadline))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,8 +291,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
|
|
||||||
// Create http vhost muxer.
|
// Create http vhost muxer.
|
||||||
if cfg.VhostHTTPPort > 0 {
|
if cfg.VhostHTTPPort > 0 {
|
||||||
|
var redirector *vhost.HTTPSRedirector
|
||||||
|
if cfg.VhostHTTPSPort > 0 {
|
||||||
|
redirector = vhost.NewHTTPSRedirector(cfg.VhostHTTPSRedirectPort)
|
||||||
|
svr.rc.HTTPSRedirector = redirector
|
||||||
|
}
|
||||||
rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{
|
rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{
|
||||||
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
|
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
|
||||||
|
HTTPSRedirector: redirector,
|
||||||
}, svr.httpVhostRouter)
|
}, svr.httpVhostRouter)
|
||||||
svr.rc.HTTPReverseProxy = rp
|
svr.rc.HTTPReverseProxy = rp
|
||||||
|
|
||||||
@@ -508,7 +514,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
}
|
}
|
||||||
case *msg.NewVisitorConn:
|
case *msg.NewVisitorConn:
|
||||||
if err = svr.RegisterVisitorConn(conn, m); err != nil {
|
if err = svr.RegisterVisitorConn(conn, m, acceptedConn.wireProtocol); err != nil {
|
||||||
xl.Warnf("register visitor conn error: %v", err)
|
xl.Warnf("register visitor conn error: %v", err)
|
||||||
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
|
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
|
||||||
ProxyName: m.ProxyName,
|
ProxyName: m.ProxyName,
|
||||||
@@ -777,6 +783,7 @@ func (svr *Service) RegisterControl(
|
|||||||
LoginMsg: loginMsg,
|
LoginMsg: loginMsg,
|
||||||
ServerCfg: svr.cfg,
|
ServerCfg: svr.cfg,
|
||||||
ClientRegistry: svr.clientRegistry,
|
ClientRegistry: svr.clientRegistry,
|
||||||
|
WireProtocol: wireProtocol,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("create new controller error: %v", err)
|
xl.Warnf("create new controller error: %v", err)
|
||||||
@@ -832,7 +839,7 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
|
|||||||
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error {
|
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
||||||
visitorUser := ""
|
visitorUser := ""
|
||||||
// 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.
|
||||||
@@ -844,5 +851,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
|
|||||||
visitorUser = ctl.sessionCtx.LoginMsg.User
|
visitorUser = ctl.sessionCtx.LoginMsg.User
|
||||||
}
|
}
|
||||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
|
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
|
|||||||
|
|
||||||
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
|
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
|
||||||
useEncryption bool, useCompression bool, visitorUser string,
|
useEncryption bool, useCompression bool, visitorUser string,
|
||||||
|
wireProtocol string,
|
||||||
) (err error) {
|
) (err error) {
|
||||||
vm.mu.RLock()
|
vm.mu.RLock()
|
||||||
defer vm.mu.RUnlock()
|
defer vm.mu.RUnlock()
|
||||||
@@ -90,7 +91,11 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
|||||||
if useCompression {
|
if useCompression {
|
||||||
rwc = libio.WithCompression(rwc)
|
rwc = libio.WithCompression(rwc)
|
||||||
}
|
}
|
||||||
err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))
|
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
|
||||||
|
err = l.l.PutConn(&wireProtocolConn{
|
||||||
|
Conn: visitorConn,
|
||||||
|
wireProtocol: wireProtocol,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
||||||
return
|
return
|
||||||
@@ -98,6 +103,15 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type wireProtocolConn struct {
|
||||||
|
net.Conn
|
||||||
|
wireProtocol string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wireProtocolConn) WireProtocol() string {
|
||||||
|
return c.wireProtocol
|
||||||
|
}
|
||||||
|
|
||||||
func (vm *Manager) CloseListener(name string) {
|
func (vm *Manager) CloseListener(name string) {
|
||||||
vm.mu.Lock()
|
vm.mu.Lock()
|
||||||
defer vm.mu.Unlock()
|
defer vm.mu.Unlock()
|
||||||
|
|||||||
61
server/visitor/visitor_test.go
Normal file
61
server/visitor/visitor_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// 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 visitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
|
||||||
|
vm := NewManager()
|
||||||
|
listener, err := vm.Listen("sudp", "secret", []string{"*"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- vm.NewConn(
|
||||||
|
"sudp",
|
||||||
|
server,
|
||||||
|
now,
|
||||||
|
util.GetAuthKey("secret", now),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"user",
|
||||||
|
wire.ProtocolV2,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
acceptedConn, err := listener.Accept()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer acceptedConn.Close()
|
||||||
|
|
||||||
|
getter, ok := acceptedConn.(interface{ WireProtocol() string })
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -158,7 +160,7 @@ webServer.port = %d
|
|||||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
ginkgo.It("baseline frps rejects current frpc forced to v2", func() {
|
ginkgo.It("baseline frps handles current frpc forced to v2 according to baseline support", func() {
|
||||||
portName := port.GenName("CompatBaselineFRPSForcedV2")
|
portName := port.GenName("CompatBaselineFRPSForcedV2")
|
||||||
clientConf := tcpClientConfig("tcp", portName, `
|
clientConf := tcpClientConfig("tcp", portName, `
|
||||||
transport.wireProtocol = "v2"
|
transport.wireProtocol = "v2"
|
||||||
@@ -170,6 +172,21 @@ transport.wireProtocol = "v2"
|
|||||||
consts.DefaultServerConfig,
|
consts.DefaultServerConfig,
|
||||||
[]string{clientConf},
|
[]string{clientConf},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// frp v0.69.0 added control connection wireProtocol v2 support, so
|
||||||
|
// baseline frps v0.69.0 and newer should accept a current frpc forced
|
||||||
|
// to v2. Older known baselines still must reject the unsupported protocol.
|
||||||
|
// For custom baselines, the version is unknown and the binary may be either
|
||||||
|
// side of the support boundary, so this versioned expectation is skipped.
|
||||||
|
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
|
||||||
|
if !knownVersion {
|
||||||
|
ginkgo.Skip(fmt.Sprintf("baseline version %q is not semver; skip versioned forced-v2 expectation", compatCtx.BaselineVersion))
|
||||||
|
}
|
||||||
|
if supportsV2 {
|
||||||
|
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
expectProcessExit(clientProcesses[0], 5*time.Second)
|
expectProcessExit(clientProcesses[0], 5*time.Second)
|
||||||
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
|
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
|
||||||
})
|
})
|
||||||
@@ -199,6 +216,39 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
|
||||||
|
version = strings.TrimPrefix(version, "v")
|
||||||
|
parts := strings.Split(version, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
major, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
minor, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
patch, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
|
||||||
|
if major != baseMajor {
|
||||||
|
return major - baseMajor
|
||||||
|
}
|
||||||
|
if minor != baseMinor {
|
||||||
|
return minor - baseMinor
|
||||||
|
}
|
||||||
|
return patch - basePatch
|
||||||
|
}
|
||||||
|
|
||||||
type wireClientInfo struct {
|
type wireClientInfo struct {
|
||||||
ClientID string `json:"clientID"`
|
ClientID string `json:"clientID"`
|
||||||
WireProtocol string `json:"wireProtocol"`
|
WireProtocol string `json:"wireProtocol"`
|
||||||
|
|||||||
@@ -98,6 +98,73 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
|
|||||||
framework.NewRequestExpect(f).PortName(bindPortName).Ensure()
|
framework.NewRequestExpect(f).PortName(bindPortName).Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
proxyWireConfig string
|
||||||
|
visitorWireConfig string
|
||||||
|
extraProxyConfig string
|
||||||
|
extraVisitorConfig string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default sudp visitor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "v2 sudp visitor",
|
||||||
|
proxyWireConfig: `transport.wireProtocol = "v2"`,
|
||||||
|
visitorWireConfig: `transport.wireProtocol = "v2"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed sudp proxy v1 visitor v2",
|
||||||
|
proxyWireConfig: `transport.wireProtocol = "v1"`,
|
||||||
|
visitorWireConfig: `transport.wireProtocol = "v2"`,
|
||||||
|
extraProxyConfig: `
|
||||||
|
transport.useEncryption = true
|
||||||
|
transport.useCompression = true
|
||||||
|
`,
|
||||||
|
extraVisitorConfig: `
|
||||||
|
transport.useEncryption = true
|
||||||
|
transport.useCompression = true
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed sudp proxy v2 visitor v1",
|
||||||
|
proxyWireConfig: `transport.wireProtocol = "v2"`,
|
||||||
|
visitorWireConfig: `transport.wireProtocol = "v1"`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
ginkgo.It(tc.name, func() {
|
||||||
|
serverConf := consts.DefaultServerConfig
|
||||||
|
bindPortName := port.GenName("WireSUDP")
|
||||||
|
clientServerConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||||
|
user = "user1"
|
||||||
|
%s
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "sudp"
|
||||||
|
type = "sudp"
|
||||||
|
secretKey = "abc"
|
||||||
|
localPort = {{ .%s }}
|
||||||
|
%s
|
||||||
|
`, tc.proxyWireConfig, framework.UDPEchoServerPort, tc.extraProxyConfig)
|
||||||
|
clientVisitorConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||||
|
user = "user1"
|
||||||
|
%s
|
||||||
|
|
||||||
|
[[visitors]]
|
||||||
|
name = "sudp-visitor"
|
||||||
|
type = "sudp"
|
||||||
|
serverName = "sudp"
|
||||||
|
secretKey = "abc"
|
||||||
|
bindPort = {{ .%s }}
|
||||||
|
%s
|
||||||
|
`, tc.visitorWireConfig, bindPortName, tc.extraVisitorConfig)
|
||||||
|
|
||||||
|
f.RunProcesses(serverConf, []string{clientServerConf, clientVisitorConf})
|
||||||
|
|
||||||
|
framework.NewRequestExpect(f).Protocol("udp").PortName(bindPortName).Ensure()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
ginkgo.It("reports client wire protocol", func() {
|
ginkgo.It("reports client wire protocol", func() {
|
||||||
webPort := f.AllocPort()
|
webPort := f.AllocPort()
|
||||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
import type { ClientInfoData } from '../types/client'
|
import type { V2Page } from './http'
|
||||||
|
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
||||||
|
|
||||||
export const getClients = () => {
|
export const getClients = () => {
|
||||||
return http.get<ClientInfoData[]>('../api/clients')
|
return http.get<ClientInfoData[]>('../api/clients')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getClientsV2 = (params: ClientListV2Params = {}) => {
|
||||||
|
return http.getV2<V2Page<ClientInfoData>>(
|
||||||
|
`../api/v2/clients${buildQueryString({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status:
|
||||||
|
params.status && params.status !== 'all' ? params.status : undefined,
|
||||||
|
q: params.q || undefined,
|
||||||
|
user: params.user,
|
||||||
|
clientID: params.clientID || undefined,
|
||||||
|
runID: params.runID || undefined,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const getClient = (key: string) => {
|
export const getClient = (key: string) => {
|
||||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ class HTTPError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface V2Envelope<T> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface V2Page<T> {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryParamValue = string | number | boolean | null | undefined
|
||||||
|
|
||||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const defaultOptions: RequestInit = {
|
const defaultOptions: RequestInit = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -34,9 +49,55 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestV2<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const defaultOptions: RequestInit = {
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||||
|
const envelope = (await response.json().catch(() => null)) as
|
||||||
|
| V2Envelope<T>
|
||||||
|
| null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HTTPError(
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
envelope?.msg || `HTTP ${response.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelope || typeof envelope.code !== 'number') {
|
||||||
|
throw new Error('Invalid API v2 response')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.code >= 400) {
|
||||||
|
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildQueryString = (
|
||||||
|
params: Record<string, QueryParamValue>,
|
||||||
|
): string => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
query.append(key, String(value))
|
||||||
|
}
|
||||||
|
const text = query.toString()
|
||||||
|
return text ? `?${text}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
export const http = {
|
export const http = {
|
||||||
get: <T>(url: string, options?: RequestInit) =>
|
get: <T>(url: string, options?: RequestInit) =>
|
||||||
request<T>(url, { ...options, method: 'GET' }),
|
request<T>(url, { ...options, method: 'GET' }),
|
||||||
|
getV2: <T>(url: string, options?: RequestInit) =>
|
||||||
|
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
|
import type { V2Page } from './http'
|
||||||
import type {
|
import type {
|
||||||
GetProxyResponse,
|
GetProxyResponse,
|
||||||
|
ProxyListV2Params,
|
||||||
ProxyStatsInfo,
|
ProxyStatsInfo,
|
||||||
|
ProxyV2Info,
|
||||||
TrafficResponse,
|
TrafficResponse,
|
||||||
} from '../types/proxy'
|
} from '../types/proxy'
|
||||||
|
|
||||||
@@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => {
|
|||||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||||
|
const page = await http.getV2<V2Page<ProxyV2Info>>(
|
||||||
|
`../api/v2/proxies${buildQueryString({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status:
|
||||||
|
params.status && params.status !== 'all' ? params.status : undefined,
|
||||||
|
q: params.q || undefined,
|
||||||
|
type: params.type || undefined,
|
||||||
|
user: params.user,
|
||||||
|
clientID: params.clientID || undefined,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: page.items.map(toLegacyProxyStats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
||||||
|
name: proxy.name,
|
||||||
|
type: proxy.type,
|
||||||
|
conf: proxy.spec,
|
||||||
|
user: proxy.user,
|
||||||
|
clientID: proxy.clientID,
|
||||||
|
todayTrafficIn: proxy.status.todayTrafficIn,
|
||||||
|
todayTrafficOut: proxy.status.todayTrafficOut,
|
||||||
|
curConns: proxy.status.curConns,
|
||||||
|
lastStartTime: proxy.status.lastStartTime,
|
||||||
|
lastCloseTime: proxy.status.lastCloseTime,
|
||||||
|
status: proxy.status.phase,
|
||||||
|
})
|
||||||
|
|
||||||
export const getProxy = (type: string, name: string) => {
|
export const getProxy = (type: string, name: string) => {
|
||||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ export interface ClientInfoData {
|
|||||||
disconnectedAt?: number
|
disconnectedAt?: number
|
||||||
online: boolean
|
online: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientListV2Params {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: 'all' | 'online' | 'offline'
|
||||||
|
q?: string
|
||||||
|
user?: string
|
||||||
|
clientID?: string
|
||||||
|
runID?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface ProxyStatsInfo {
|
export interface ProxyStatsInfo {
|
||||||
name: string
|
name: string
|
||||||
|
type?: string
|
||||||
conf: any
|
conf: any
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
@@ -15,6 +16,34 @@ export interface GetProxyResponse {
|
|||||||
proxies: ProxyStatsInfo[]
|
proxies: ProxyStatsInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyListV2Params {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: 'all' | 'online' | 'offline'
|
||||||
|
q?: string
|
||||||
|
type?: string
|
||||||
|
user?: string
|
||||||
|
clientID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2Info {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
user: string
|
||||||
|
clientID: string
|
||||||
|
spec: any
|
||||||
|
status: ProxyV2Status
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2Status {
|
||||||
|
phase: string
|
||||||
|
todayTrafficIn: number
|
||||||
|
todayTrafficOut: number
|
||||||
|
curConns: number
|
||||||
|
lastStartTime: string
|
||||||
|
lastCloseTime: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficResponse {
|
export interface TrafficResponse {
|
||||||
name: string
|
name: string
|
||||||
trafficIn: number[]
|
trafficIn: number[]
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
>
|
>
|
||||||
<span class="status-dot" :class="tab.value"></span>
|
<span class="status-dot" :class="tab.value"></span>
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
<span class="tab-label">{{ tab.label }}</span>
|
||||||
<span class="tab-count">{{ tab.count }}</span>
|
<span v-if="tab.count !== null" class="tab-count">{{
|
||||||
|
tab.count
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +35,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="clients-content">
|
<div v-loading="loading" class="clients-content">
|
||||||
<div v-if="filteredClients.length > 0" class="clients-list">
|
<div v-if="clients.length > 0" class="clients-list">
|
||||||
<ClientCard
|
<ClientCard
|
||||||
v-for="client in filteredClients"
|
v-for="client in clients"
|
||||||
:key="client.key"
|
:key="client.key"
|
||||||
:client="client"
|
:client="client"
|
||||||
/>
|
/>
|
||||||
@@ -44,82 +46,123 @@
|
|||||||
<el-empty description="No clients found" />
|
<el-empty description="No clients found" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > 0" class="pagination-section">
|
||||||
|
<ElPagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import ClientCard from '../components/ClientCard.vue'
|
import ClientCard from '../components/ClientCard.vue'
|
||||||
import { getClients } from '../api/client'
|
import { getClientsV2 } from '../api/client'
|
||||||
|
|
||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
const stats = computed(() => {
|
let requestSeq = 0
|
||||||
const total = clients.value.length
|
|
||||||
const online = clients.value.filter((c) => c.online).length
|
|
||||||
const offline = total - online
|
|
||||||
return { total, online, offline }
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusTabs = computed(() => [
|
const statusTabs = computed(() => [
|
||||||
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
{
|
||||||
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
value: 'all' as const,
|
||||||
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
label: 'All',
|
||||||
|
count: statusFilter.value === 'all' ? total.value : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'online' as const,
|
||||||
|
label: 'Online',
|
||||||
|
count: statusFilter.value === 'online' ? total.value : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'offline' as const,
|
||||||
|
label: 'Offline',
|
||||||
|
count: statusFilter.value === 'offline' ? total.value : null,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const filteredClients = computed(() => {
|
const fetchData = async (silent = false) => {
|
||||||
let result = clients.value
|
const seq = ++requestSeq
|
||||||
|
if (!silent) loading.value = true
|
||||||
// Filter by status
|
|
||||||
if (statusFilter.value === 'online') {
|
|
||||||
result = result.filter((c) => c.online)
|
|
||||||
} else if (statusFilter.value === 'offline') {
|
|
||||||
result = result.filter((c) => !c.online)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text
|
|
||||||
if (searchText.value) {
|
|
||||||
result = result.filter((c) => c.matchesFilter(searchText.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: online first, then by display name
|
|
||||||
result.sort((a, b) => {
|
|
||||||
if (a.online !== b.online) {
|
|
||||||
return a.online ? -1 : 1
|
|
||||||
}
|
|
||||||
return a.displayName.localeCompare(b.displayName)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const json = await getClients()
|
const data = await getClientsV2({
|
||||||
clients.value = json.map((data) => new Client(data))
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
q: searchText.value.trim(),
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
|
page.value = maxPage
|
||||||
|
await fetchData(silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.value = data.items.map((item) => new Client(item))
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch clients: ' + error.message,
|
message: 'Failed to fetch clients: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === requestSeq) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSearchDebounce = () => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
refreshTimer = window.setInterval(() => {
|
refreshTimer = window.setInterval(() => {
|
||||||
fetchData()
|
fetchData(true)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
resetPageAndFetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
fetchData()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
@@ -137,6 +193,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
clearSearchDebounce()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -274,6 +331,11 @@ onUnmounted(() => {
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
/* Dark mode adjustments */
|
||||||
html.dark .status-tab {
|
html.dark .status-tab {
|
||||||
background: var(--el-bg-color-overlay);
|
background: var(--el-bg-color-overlay);
|
||||||
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
|
|||||||
.status-tab {
|
.status-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-section">
|
<div class="actions-section">
|
||||||
<ActionButton variant="outline" size="small" @click="fetchData">
|
<ActionButton variant="outline" size="small" @click="refreshData">
|
||||||
Refresh
|
Refresh
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
@@ -74,9 +74,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="proxies-content">
|
<div v-loading="loading" class="proxies-content">
|
||||||
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
<div v-if="proxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in filteredProxies"
|
v-for="proxy in proxies"
|
||||||
:key="`${proxy.type}:${proxy.name}`"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
:show-type="activeType === 'all'"
|
:show-type="activeType === 'all'"
|
||||||
@@ -87,6 +87,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > 0" class="pagination-section">
|
||||||
|
<ElPagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
v-model="showClearDialog"
|
v-model="showClearDialog"
|
||||||
title="Clear Offline"
|
title="Clear Offline"
|
||||||
@@ -99,9 +111,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import ActionButton from '@shared/components/ActionButton.vue'
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
@@ -119,12 +131,13 @@ import ProxyCard from '../components/ProxyCard.vue'
|
|||||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||||
import {
|
import {
|
||||||
getProxiesByType,
|
getProxiesV2,
|
||||||
clearOfflineProxies as apiClearOfflineProxies,
|
clearOfflineProxies as apiClearOfflineProxies,
|
||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import { getClients } from '../api/client'
|
import { getClientsV2 } from '../api/client'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -149,6 +162,12 @@ const searchText = ref('')
|
|||||||
const showClearDialog = ref(false)
|
const showClearDialog = ref(false)
|
||||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
const clientIDFilter = ref((route.query.clientID as string) || '')
|
||||||
const userFilter = ref((route.query.user as string) || '')
|
const userFilter = ref((route.query.user as string) || '')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const maxV2PageSize = 100
|
||||||
|
let requestSeq = 0
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
const clientOptions = computed(() => {
|
const clientOptions = computed(() => {
|
||||||
return clients.value
|
return clients.value
|
||||||
@@ -193,58 +212,6 @@ const selectedClientInList = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredProxies = computed(() => {
|
|
||||||
let result = proxies.value
|
|
||||||
|
|
||||||
// Filter by clientID and user if specified
|
|
||||||
if (clientIDFilter.value) {
|
|
||||||
result = result.filter(
|
|
||||||
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text across multiple fields
|
|
||||||
if (searchText.value) {
|
|
||||||
const search = searchText.value.toLowerCase()
|
|
||||||
result = result.filter((p) => {
|
|
||||||
const fields: unknown[] = [
|
|
||||||
p.name,
|
|
||||||
p.type,
|
|
||||||
p.clientID,
|
|
||||||
p.user,
|
|
||||||
p.addr,
|
|
||||||
p.port,
|
|
||||||
p.customDomains,
|
|
||||||
p.subdomain,
|
|
||||||
]
|
|
||||||
return fields.some((v) => matchesSearch(v, search))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// Normalize a field of unknown shape (string / number / array / null) to a
|
|
||||||
// lowercase string for case-insensitive substring matching. Arrays are joined
|
|
||||||
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
|
|
||||||
const matchesSearch = (value: unknown, needle: string): boolean => {
|
|
||||||
if (value === null || value === undefined) return false
|
|
||||||
let str: string
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
str = value
|
|
||||||
.filter((v) => v !== null && v !== undefined)
|
|
||||||
.map((v) => String(v))
|
|
||||||
.join(' ')
|
|
||||||
} else if (typeof value === 'number') {
|
|
||||||
if (value === 0) return false
|
|
||||||
str = String(value)
|
|
||||||
} else {
|
|
||||||
str = String(value)
|
|
||||||
}
|
|
||||||
if (!str) return false
|
|
||||||
return str.toLowerCase().includes(needle)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClientFilterChange = (key: string) => {
|
const onClientFilterChange = (key: string) => {
|
||||||
if (key) {
|
if (key) {
|
||||||
const client = clientOptions.value.find((c) => c.key === key)
|
const client = clientOptions.value.find((c) => c.key === key)
|
||||||
@@ -263,122 +230,174 @@ const onClientFilterChange = (key: string) => {
|
|||||||
|
|
||||||
const fetchClients = async () => {
|
const fetchClients = async () => {
|
||||||
try {
|
try {
|
||||||
const json = await getClients()
|
const allClients: Client[] = []
|
||||||
clients.value = json.map((data) => new Client(data))
|
let nextPage = 1
|
||||||
} catch {
|
let totalClients = 0
|
||||||
// Ignore errors when fetching clients
|
|
||||||
|
do {
|
||||||
|
const data = await getClientsV2({
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: maxV2PageSize,
|
||||||
|
})
|
||||||
|
allClients.push(...data.items.map((item) => new Client(item)))
|
||||||
|
totalClients = data.total
|
||||||
|
nextPage += 1
|
||||||
|
} while (allClients.length < totalClients)
|
||||||
|
|
||||||
|
clients.value = allClients
|
||||||
|
} catch (err) {
|
||||||
|
// Client dropdown is a non-critical side load; log for diagnostics
|
||||||
|
// but don't surface a toast (would compete with the main fetch error).
|
||||||
|
console.warn('Failed to fetch clients for filter:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server info cache
|
// Server info cache - cache the Promise itself so concurrent first calls
|
||||||
let serverInfo: {
|
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||||
|
type ServerInfoLite = {
|
||||||
vhostHTTPPort: number
|
vhostHTTPPort: number
|
||||||
vhostHTTPSPort: number
|
vhostHTTPSPort: number
|
||||||
tcpmuxHTTPConnectPort: number
|
tcpmuxHTTPConnectPort: number
|
||||||
subdomainHost: string
|
subdomainHost: string
|
||||||
} | null = null
|
}
|
||||||
|
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||||
|
|
||||||
const fetchServerInfo = async () => {
|
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||||
if (serverInfo) return serverInfo
|
if (!serverInfoPromise) {
|
||||||
const res = await getServerInfo()
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
serverInfo = res
|
// Allow retry after failure
|
||||||
return serverInfo
|
serverInfoPromise = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return serverInfoPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxies = async (
|
const convertProxy = async (
|
||||||
type: string,
|
proxy: ProxyStatsInfo,
|
||||||
json: any,
|
): Promise<BaseProxy | null> => {
|
||||||
): Promise<BaseProxy[]> => {
|
const type = proxy.type || activeType.value
|
||||||
if (type === 'tcp') {
|
if (type === 'tcp') {
|
||||||
return json.proxies.map((p: any) => new TCPProxy(p))
|
return new TCPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'udp') {
|
if (type === 'udp') {
|
||||||
return json.proxies.map((p: any) => new UDPProxy(p))
|
return new UDPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPPort) {
|
if (info && info.vhostHTTPPort) {
|
||||||
return json.proxies.map(
|
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||||
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPSPort) {
|
if (info && info.vhostHTTPSPort) {
|
||||||
return json.proxies.map(
|
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||||
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
if (info && info.tcpmuxHTTPConnectPort) {
|
||||||
return json.proxies.map(
|
return new TCPMuxProxy(
|
||||||
(p: any) =>
|
proxy,
|
||||||
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
info.tcpmuxHTTPConnectPort,
|
||||||
|
info.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'stcp') {
|
if (type === 'stcp') {
|
||||||
return json.proxies.map((p: any) => new STCPProxy(p))
|
return new STCPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'sudp') {
|
if (type === 'sudp') {
|
||||||
return json.proxies.map((p: any) => new SUDPProxy(p))
|
return new SUDPProxy(proxy)
|
||||||
}
|
}
|
||||||
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
||||||
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||||
return json.proxies.map((p: any) => {
|
const bp = new BaseProxy(proxy)
|
||||||
const bp = new BaseProxy(p)
|
bp.type = type
|
||||||
bp.type = type
|
return bp
|
||||||
return bp
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allProxyTypes = [
|
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||||
'tcp',
|
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||||
'udp',
|
return converted.filter((item): item is BaseProxy => item !== null)
|
||||||
'http',
|
}
|
||||||
'https',
|
|
||||||
'tcpmux',
|
|
||||||
'stcp',
|
|
||||||
'xtcp',
|
|
||||||
'sudp',
|
|
||||||
]
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async (silent = false) => {
|
||||||
loading.value = true
|
const seq = ++requestSeq
|
||||||
proxies.value = []
|
if (!silent) loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const type = activeType.value
|
const q = searchText.value.trim()
|
||||||
|
const data = await getProxiesV2({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
type: activeType.value === 'all' ? undefined : activeType.value,
|
||||||
|
q: q || undefined,
|
||||||
|
clientID: clientIDFilter.value || undefined,
|
||||||
|
user: clientIDFilter.value ? userFilter.value : undefined,
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
if (type === 'all') {
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
const results = await Promise.all(
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
allProxyTypes.map(async (t) => {
|
page.value = maxPage
|
||||||
const json = await getProxiesByType(t)
|
await fetchData(silent)
|
||||||
return convertProxies(t, json)
|
return
|
||||||
}),
|
|
||||||
)
|
|
||||||
proxies.value = results.flat()
|
|
||||||
} else {
|
|
||||||
const json = await getProxiesByType(type)
|
|
||||||
proxies.value = await convertProxies(type, json)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const converted = await convertProxies(data.items)
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
proxies.value = converted
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch proxies: ' + error.message,
|
message: 'Failed to fetch proxies: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === requestSeq) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSearchDebounce = () => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearConfirm = async () => {
|
const handleClearConfirm = async () => {
|
||||||
showClearDialog.value = false
|
showClearDialog.value = false
|
||||||
await clearOfflineProxies()
|
await clearOfflineProxies()
|
||||||
@@ -402,20 +421,36 @@ const clearOfflineProxies = async () => {
|
|||||||
|
|
||||||
// Watch for type changes
|
// Watch for type changes
|
||||||
watch(activeType, (newType) => {
|
watch(activeType, (newType) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
// Update route but preserve query params
|
// Update route but preserve query params
|
||||||
router.replace({ params: { type: newType }, query: route.query })
|
router.replace({ params: { type: newType }, query: route.query })
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
fetchData()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
// Watch for route query changes (client filter)
|
// Watch for route query changes (client filter)
|
||||||
watch(
|
watch(
|
||||||
() => [route.query.clientID, route.query.user],
|
() => [route.query.clientID, route.query.user],
|
||||||
([newClientID, newUser]) => {
|
([newClientID, newUser]) => {
|
||||||
clientIDFilter.value = (newClientID as string) || ''
|
clientIDFilter.value = (newClientID as string) || ''
|
||||||
userFilter.value = (newUser as string) || ''
|
userFilter.value = (newUser as string) || ''
|
||||||
|
resetPageAndFetch()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
})
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
fetchData()
|
fetchData()
|
||||||
fetchClients()
|
fetchClients()
|
||||||
@@ -539,6 +574,11 @@ fetchClients()
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-row {
|
.search-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -547,5 +587,9 @@ fetchClients()
|
|||||||
.client-filter {
|
.client-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
70
web/package-lock.json
generated
70
web/package-lock.json
generated
@@ -954,76 +954,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/kit": {
|
|
||||||
"version": "3.21.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
|
|
||||||
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"c12": "^3.3.3",
|
|
||||||
"consola": "^3.4.2",
|
|
||||||
"defu": "^6.1.4",
|
|
||||||
"destr": "^2.0.5",
|
|
||||||
"errx": "^0.1.0",
|
|
||||||
"exsolve": "^1.0.8",
|
|
||||||
"ignore": "^7.0.5",
|
|
||||||
"jiti": "^2.6.1",
|
|
||||||
"klona": "^2.0.6",
|
|
||||||
"knitwork": "^1.3.0",
|
|
||||||
"mlly": "^1.8.1",
|
|
||||||
"ohash": "^2.0.11",
|
|
||||||
"pathe": "^2.0.3",
|
|
||||||
"pkg-types": "^2.3.0",
|
|
||||||
"rc9": "^3.0.0",
|
|
||||||
"scule": "^1.3.0",
|
|
||||||
"semver": "^7.7.4",
|
|
||||||
"tinyglobby": "^0.2.15",
|
|
||||||
"ufo": "^1.6.3",
|
|
||||||
"unctx": "^2.5.0",
|
|
||||||
"untyped": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit/node_modules/confbox": {
|
|
||||||
"version": "0.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
|
||||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit/node_modules/ignore": {
|
|
||||||
"version": "7.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
|
||||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit/node_modules/pkg-types": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"confbox": "^0.2.2",
|
|
||||||
"exsolve": "^1.0.7",
|
|
||||||
"pathe": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user