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
|
||||
|
||||
# AI
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.superpowers/
|
||||
|
||||
# TLS
|
||||
.autotls-cache
|
||||
|
||||
4
Makefile
4
Makefile
@@ -1,6 +1,8 @@
|
||||
export PATH := $(PATH):`go env GOPATH`/bin
|
||||
export GO111MODULE=on
|
||||
LDFLAGS := -s -w
|
||||
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep),
|
||||
# which uses //go:linkname to reference net.zoneCache on android targets.
|
||||
LDFLAGS := -s -w -checklinkname=0
|
||||
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
||||
FRP_COMPAT_BASELINE_COUNT ?= 8
|
||||
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
|
||||
|
||||
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
|
||||
|
||||
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
||||
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
|
||||
* Wire protocol v2 now negotiates AEAD control-channel encryption. Supported algorithms are `xchacha20-poly1305` and `aes-256-gcm`; frpc advertises its preferred order based on local AES-GCM hardware support, and frps selects the first supported algorithm from that list.
|
||||
* `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.
|
||||
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
||||
* 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -160,9 +162,44 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
|
||||
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
||||
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
||||
if err != nil {
|
||||
xl.Warnf("[%s] start error: %v", proxyName, err)
|
||||
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
|
||||
} else {
|
||||
xl.Infof("[%s] start proxy success", proxyName)
|
||||
xl.Infof("[%s] 成功启动隧道", proxyName)
|
||||
if inMsg.RemoteAddr != "" {
|
||||
// Get proxy type to format access message
|
||||
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
|
||||
proxyType := status.Type
|
||||
remoteAddr := inMsg.RemoteAddr
|
||||
var accessMsg string
|
||||
|
||||
switch proxyType {
|
||||
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
|
||||
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
|
||||
if strings.HasPrefix(remoteAddr, ":") {
|
||||
serverAddr := ctl.sessionCtx.Common.ServerAddr
|
||||
remoteAddr = serverAddr + remoteAddr
|
||||
}
|
||||
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
||||
case "http", "https":
|
||||
// Format as URL with protocol
|
||||
protocol := proxyType
|
||||
addr := remoteAddr
|
||||
// Remove standard ports for cleaner URL
|
||||
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
|
||||
addr = strings.TrimSuffix(addr, ":80")
|
||||
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
|
||||
addr = strings.TrimSuffix(addr, ":443")
|
||||
}
|
||||
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
|
||||
default:
|
||||
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
||||
}
|
||||
|
||||
xl.Infof("[%s] %s", proxyName, accessMsg)
|
||||
} else {
|
||||
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -70,6 +72,7 @@ func NewProxy(
|
||||
|
||||
baseProxy := BaseProxy{
|
||||
baseCfg: pxyConf.GetBaseConfig(),
|
||||
configurer: pxyConf,
|
||||
clientCfg: clientCfg,
|
||||
encryptionKey: encryptionKey,
|
||||
limiter: limiter,
|
||||
@@ -88,6 +91,7 @@ func NewProxy(
|
||||
|
||||
type BaseProxy struct {
|
||||
baseCfg *v1.ProxyBaseConfig
|
||||
configurer v1.ProxyConfigurer
|
||||
clientCfg *v1.ClientCommonConfig
|
||||
encryptionKey []byte
|
||||
msgTransporter transport.MessageTransporter
|
||||
@@ -107,6 +111,7 @@ func (pxy *BaseProxy) Run() error {
|
||||
if pxy.baseCfg.Plugin.Type != "" {
|
||||
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
||||
Name: pxy.baseCfg.Name,
|
||||
HostAllowList: pxy.getPluginHostAllowList(),
|
||||
VnetController: pxy.vnetController,
|
||||
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
||||
if err != nil {
|
||||
@@ -117,6 +122,39 @@ func (pxy *BaseProxy) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pxy *BaseProxy) getPluginHostAllowList() []string {
|
||||
dedupHosts := make([]string, 0)
|
||||
addHost := func(host string) {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
// autocert.HostWhitelist only supports exact host names.
|
||||
if strings.Contains(host, "*") {
|
||||
return
|
||||
}
|
||||
if !slices.Contains(dedupHosts, host) {
|
||||
dedupHosts = append(dedupHosts, host)
|
||||
}
|
||||
}
|
||||
|
||||
switch cfg := pxy.configurer.(type) {
|
||||
case *v1.HTTPProxyConfig:
|
||||
for _, host := range cfg.CustomDomains {
|
||||
addHost(host)
|
||||
}
|
||||
case *v1.HTTPSProxyConfig:
|
||||
for _, host := range cfg.CustomDomains {
|
||||
addHost(host)
|
||||
}
|
||||
case *v1.TCPMuxProxyConfig:
|
||||
for _, host := range cfg.CustomDomains {
|
||||
addHost(host)
|
||||
}
|
||||
}
|
||||
return dedupHosts
|
||||
}
|
||||
|
||||
func (pxy *BaseProxy) Close() {
|
||||
if pxy.proxyPlugin != nil {
|
||||
pxy.proxyPlugin.Close()
|
||||
|
||||
@@ -159,7 +159,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||
}
|
||||
}
|
||||
if len(delPxyNames) > 0 {
|
||||
xl.Infof("proxy removed: %s", delPxyNames)
|
||||
xl.Infof("隧道移除: %s", delPxyNames)
|
||||
}
|
||||
|
||||
addPxyNames := make([]string, 0)
|
||||
@@ -177,6 +177,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
|
||||
readCh := make(chan *msg.UDPPacket, 1024)
|
||||
sendCh := make(chan msg.Message, 1024)
|
||||
isClose := false
|
||||
@@ -109,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
|
||||
defer closeFn()
|
||||
|
||||
for {
|
||||
@@ -122,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -137,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
closeFn()
|
||||
xl.Infof("writer goroutine for sudp work connection closed")
|
||||
@@ -148,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
switch m := rawMsg.(type) {
|
||||
case *msg.UDPPacket:
|
||||
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
||||
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
|
||||
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
|
||||
case *msg.Ping:
|
||||
xl.Tracef("frpc send ping message to frpc visitor")
|
||||
}
|
||||
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
|
||||
xl.Errorf("sudp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -184,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(workConn, sendCh)
|
||||
go workConnReaderFn(workConn, readCh)
|
||||
go workConnSenderFn(payloadConn, sendCh)
|
||||
go workConnReaderFn(payloadConn, readCh)
|
||||
go heartbeatFn(sendCh)
|
||||
|
||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||
|
||||
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
|
||||
|
||||
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
|
||||
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
|
||||
// close resources related with old workConn
|
||||
pxy.Close()
|
||||
|
||||
@@ -99,15 +99,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
|
||||
pxy.mu.Lock()
|
||||
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.sendCh = make(chan msg.Message, 1024)
|
||||
pxy.closed = false
|
||||
pxy.mu.Unlock()
|
||||
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||
for {
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +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() {
|
||||
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:
|
||||
xl.Tracef("send ping message to udp workConn")
|
||||
}
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
|
||||
xl.Errorf("udp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -151,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
||||
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||
go heartbeatFn(pxy.sendCh)
|
||||
|
||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||
|
||||
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
defer conn.Close()
|
||||
var natHoleSidMsg msg.NatHoleSid
|
||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
||||
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||
if err != nil {
|
||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||
return
|
||||
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
||||
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
|
||||
}
|
||||
|
||||
func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
|
||||
workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
|
||||
var natHoleSidMsg msg.NatHoleSid
|
||||
if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &natHoleSidMsg, nil
|
||||
}
|
||||
|
||||
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
listenConn.Close()
|
||||
|
||||
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{}
|
||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||
svr.stop()
|
||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
|
||||
}
|
||||
|
||||
go svr.keepControllerWorking()
|
||||
@@ -305,7 +305,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
xl := xlog.FromContextSafe(svr.ctx)
|
||||
|
||||
loginFunc := func() (bool, error) {
|
||||
xl.Infof("try to connect to server...")
|
||||
xl.Infof("尝试连接到服务器...")
|
||||
dialer := &controlSessionDialer{
|
||||
ctx: svr.ctx,
|
||||
common: svr.common,
|
||||
@@ -316,7 +316,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
}
|
||||
sessionCtx, err := dialer.Dial(svr.runID)
|
||||
if err != nil {
|
||||
xl.Warnf("connect to server error: %v", err)
|
||||
xl.Warnf("连接服务器错误: %v", err)
|
||||
if firstLoginExit {
|
||||
svr.cancel(cancelErr{Err: err})
|
||||
}
|
||||
|
||||
@@ -113,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
|
||||
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
xl := xlog.FromContextSafe(sv.ctx)
|
||||
xl.Debugf("starting sudp proxy worker")
|
||||
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
closeCh := make(chan struct{})
|
||||
|
||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||
workConnReaderFn := func(conn net.Conn) {
|
||||
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
payloadConn.Close()
|
||||
close(closeCh)
|
||||
wg.Done()
|
||||
}()
|
||||
@@ -133,13 +134,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
)
|
||||
|
||||
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
||||
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||
return
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||
switch m := rawMsg.(type) {
|
||||
case *msg.Ping:
|
||||
xl.Debugf("frpc visitor get ping message from frpc")
|
||||
@@ -157,15 +158,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnSenderFn := func(conn net.Conn) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
payloadConn.Close()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
var errRet error
|
||||
if firstPacket != nil {
|
||||
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -180,7 +181,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
return
|
||||
}
|
||||
|
||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -191,8 +192,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnReaderFn(workConn)
|
||||
go workConnSenderFn(workConn)
|
||||
go workConnReaderFn(payloadConn)
|
||||
go workConnSenderFn(payloadConn)
|
||||
|
||||
wg.Wait()
|
||||
xl.Infof("sudp worker is closed")
|
||||
|
||||
@@ -54,7 +54,11 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
|
||||
Use: name,
|
||||
Short: short,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||
fmt.Println("frpc: the configuration file is not specified")
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -48,8 +48,19 @@ var natholeDiscoveryCmd = &cobra.Command{
|
||||
Short: "Discover nathole information from stun server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// ignore error here, because we can use command line parameters
|
||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||
if err != nil {
|
||||
var cfg *v1.ClientCommonConfig
|
||||
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
|
||||
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||
if err != nil {
|
||||
cfg = &v1.ClientCommonConfig{}
|
||||
if err := cfg.Complete(); err != nil {
|
||||
fmt.Printf("failed to complete config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
cfg = loaded
|
||||
}
|
||||
} else {
|
||||
cfg = &v1.ClientCommonConfig{}
|
||||
if err := cfg.Complete(); err != nil {
|
||||
fmt.Printf("failed to complete config: %v\n", err)
|
||||
|
||||
@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
|
||||
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
|
||||
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
@@ -141,11 +141,13 @@ func startService(
|
||||
visitorCfgs []v1.VisitorConfigurer,
|
||||
unsafeFeatures *security.UnsafeFeatures,
|
||||
cfgFile string,
|
||||
nodeName string,
|
||||
tunnelRemark string,
|
||||
) error {
|
||||
configSource := source.NewConfigSource()
|
||||
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
||||
return fmt.Errorf("failed to set config source: %w", err)
|
||||
}
|
||||
aggregator := source.NewAggregator(configSource)
|
||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
|
||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ package sub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -35,24 +38,28 @@ import (
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/policy/featuregate"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
"github.com/fatedier/frp/pkg/util/banner"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
"github.com/fatedier/frp/pkg/util/version"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
cfgFiles []string
|
||||
cfgDir string
|
||||
showVersion bool
|
||||
strictConfigMode bool
|
||||
allowUnsafe []string
|
||||
authTokens []string
|
||||
|
||||
bannerDisplayed bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
|
||||
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
||||
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
|
||||
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
|
||||
|
||||
rootCmd.PersistentFlags().StringSliceVarP(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
|
||||
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
||||
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
||||
}
|
||||
@@ -68,15 +75,30 @@ var rootCmd = &cobra.Command{
|
||||
|
||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
||||
|
||||
// If authTokens is provided, fetch config from API
|
||||
if len(authTokens) > 0 {
|
||||
err := runClientWithTokens(authTokens, unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
|
||||
// Note that it's only designed for testing. It's not guaranteed to be stable.
|
||||
if cfgDir != "" {
|
||||
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If multiple config files are specified, run one frpc service for each file
|
||||
if len(cfgFiles) > 1 {
|
||||
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not show command usage here.
|
||||
err := runClient(cfgFile, unsafeFeatures)
|
||||
err := runClient(cfgFiles[0], unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
@@ -106,6 +128,29 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
|
||||
return err
|
||||
}
|
||||
|
||||
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Display banner first
|
||||
banner.DisplayBanner()
|
||||
bannerDisplayed = true
|
||||
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
|
||||
|
||||
for _, cfgFile := range cfgFiles {
|
||||
wg.Add(1)
|
||||
// Add a small delay to avoid log output mixing
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
go func(path string) {
|
||||
defer wg.Done()
|
||||
err := runClient(path, unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
|
||||
}
|
||||
}(cfgFile)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
@@ -186,7 +231,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
|
||||
return err
|
||||
}
|
||||
|
||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
|
||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "")
|
||||
}
|
||||
|
||||
func startServiceWithAggregator(
|
||||
@@ -194,12 +239,25 @@ func startServiceWithAggregator(
|
||||
aggregator *source.Aggregator,
|
||||
unsafeFeatures *security.UnsafeFeatures,
|
||||
cfgFile string,
|
||||
nodeName string,
|
||||
tunnelRemark string,
|
||||
) error {
|
||||
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
||||
|
||||
// Display banner only once before starting the first service
|
||||
if !bannerDisplayed {
|
||||
banner.DisplayBanner()
|
||||
bannerDisplayed = true
|
||||
}
|
||||
|
||||
// Display node information if available
|
||||
if nodeName != "" {
|
||||
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
|
||||
}
|
||||
|
||||
if cfgFile != "" {
|
||||
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
|
||||
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
|
||||
log.Infof("启动 frpc 服务 [%s]", cfgFile)
|
||||
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
|
||||
}
|
||||
svr, err := client.NewService(client.ServiceOptions{
|
||||
Common: cfg,
|
||||
@@ -217,3 +275,189 @@ func startServiceWithAggregator(
|
||||
}
|
||||
return svr.Run(context.Background())
|
||||
}
|
||||
|
||||
// APIResponse represents the response from LoliaFRP API
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Config string `json:"config"`
|
||||
NodeName string `json:"node_name"`
|
||||
TunnelRemark string `json:"tunnel_remark"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TokenInfo stores parsed id and token from the -t parameter
|
||||
type TokenInfo struct {
|
||||
ID string
|
||||
Token string
|
||||
}
|
||||
|
||||
func runClientWithTokens(tokens []string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||
// Parse all tokens (format: id:token)
|
||||
tokenInfos := make([]TokenInfo, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
parts := strings.SplitN(t, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid token format '%s', expected 'id:token'", t)
|
||||
}
|
||||
tokenInfos = append(tokenInfos, TokenInfo{
|
||||
ID: strings.TrimSpace(parts[0]),
|
||||
Token: strings.TrimSpace(parts[1]),
|
||||
})
|
||||
}
|
||||
|
||||
// Group tokens by token value (same token can have multiple IDs)
|
||||
tokenToIDs := make(map[string][]string)
|
||||
for _, ti := range tokenInfos {
|
||||
tokenToIDs[ti.Token] = append(tokenToIDs[ti.Token], ti.ID)
|
||||
}
|
||||
|
||||
// If we have multiple different tokens, start one service for each token group
|
||||
if len(tokenToIDs) > 1 {
|
||||
return runMultipleClientsWithTokens(tokenToIDs, unsafeFeatures)
|
||||
}
|
||||
|
||||
// Get the single token and all its IDs
|
||||
var token string
|
||||
var ids []string
|
||||
for t, idList := range tokenToIDs {
|
||||
token = t
|
||||
ids = idList
|
||||
break
|
||||
}
|
||||
|
||||
return runClientWithTokenAndIDs(token, ids, unsafeFeatures)
|
||||
}
|
||||
|
||||
func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||
// Get API server address from environment variable
|
||||
apiServer := os.Getenv("LOLIA_API")
|
||||
if apiServer == "" {
|
||||
apiServer = "https://api.lolia.link"
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ","))
|
||||
// URL is constructed from trusted source (environment variable or hardcoded)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API request: %v", err)
|
||||
}
|
||||
// Carry client version in User-Agent so the API knows which frpc version is requesting
|
||||
req.Header.Set("User-Agent", version.Full())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch config from API: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return fmt.Errorf("failed to decode API response: %v", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return fmt.Errorf("API error: %s", apiResp.Msg)
|
||||
}
|
||||
|
||||
// Decode base64 config
|
||||
configBytes, err := base64.StdEncoding.DecodeString(apiResp.Data.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode base64 config: %v", err)
|
||||
}
|
||||
|
||||
// Load config directly from bytes
|
||||
return runClientWithConfig(configBytes, unsafeFeatures, apiResp.Data.NodeName, apiResp.Data.TunnelRemark)
|
||||
}
|
||||
|
||||
func runMultipleClientsWithTokens(tokenToIDs map[string][]string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Display banner first
|
||||
banner.DisplayBanner()
|
||||
bannerDisplayed = true
|
||||
log.Infof("检测到 %d 个不同的 token,将并行启动多个 frpc 服务实例", len(tokenToIDs))
|
||||
|
||||
index := 0
|
||||
for token, ids := range tokenToIDs {
|
||||
wg.Add(1)
|
||||
currentIndex := index
|
||||
currentToken := token
|
||||
currentIDs := ids
|
||||
totalCount := len(tokenToIDs)
|
||||
|
||||
// Add a small delay to avoid log output mixing
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
maskedToken := currentToken
|
||||
if len(maskedToken) > 6 {
|
||||
maskedToken = maskedToken[:3] + "***" + maskedToken[len(maskedToken)-3:]
|
||||
} else {
|
||||
maskedToken = "***"
|
||||
}
|
||||
log.Infof("[%d/%d] 启动 token: %s (IDs: %v)", currentIndex+1, totalCount, maskedToken, currentIDs)
|
||||
err := runClientWithTokenAndIDs(currentToken, currentIDs, unsafeFeatures)
|
||||
if err != nil {
|
||||
fmt.Printf("\nToken [%s] 启动失败: %v\n", maskedToken, err)
|
||||
}
|
||||
}()
|
||||
index++
|
||||
}
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeatures, nodeName, tunnelRemark string) error {
|
||||
// Render template first
|
||||
renderedBytes, err := config.RenderWithTemplate(configBytes, config.GetValues())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render template: %v", err)
|
||||
}
|
||||
|
||||
var allCfg v1.ClientConfig
|
||||
if err := config.LoadConfigure(renderedBytes, &allCfg, strictConfigMode); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &allCfg.ClientCommonConfig
|
||||
proxyCfgs := make([]v1.ProxyConfigurer, 0, len(allCfg.Proxies))
|
||||
for _, c := range allCfg.Proxies {
|
||||
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
|
||||
}
|
||||
visitorCfgs := make([]v1.VisitorConfigurer, 0, len(allCfg.Visitors))
|
||||
for _, c := range allCfg.Visitors {
|
||||
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
|
||||
}
|
||||
|
||||
// Call Complete to fill in default values
|
||||
if err := cfg.Complete(); err != nil {
|
||||
return fmt.Errorf("failed to complete config: %v", err)
|
||||
}
|
||||
|
||||
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
|
||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||
|
||||
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",
|
||||
Short: "Verify that the configures is valid",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if cfgFile == "" {
|
||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||
fmt.Println("frpc: the configuration file is not specified")
|
||||
return nil
|
||||
}
|
||||
|
||||
cfgFile := cfgFiles[0]
|
||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
|
||||
@@ -275,6 +275,10 @@ localIP = "127.0.0.1"
|
||||
localPort = 8000
|
||||
subdomain = "web02"
|
||||
customDomains = ["web02.yourdomain.com"]
|
||||
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
|
||||
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
|
||||
# on the same domain always takes precedence over the redirect.
|
||||
# httpRedirect = true
|
||||
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
|
||||
# v1 or v2 or empty
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
@@ -336,6 +340,14 @@ type = "https2http"
|
||||
localAddr = "127.0.0.1:80"
|
||||
crtPath = "./server.crt"
|
||||
keyPath = "./server.key"
|
||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||
# [proxies.plugin.autoTLS]
|
||||
# enable = true
|
||||
# email = "admin@example.com"
|
||||
# cacheDir = "./.autotls-cache"
|
||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||
# hostAllowList = ["test.yourdomain.com"]
|
||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
hostHeaderRewrite = "127.0.0.1"
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
|
||||
@@ -348,6 +360,14 @@ type = "https2https"
|
||||
localAddr = "127.0.0.1:443"
|
||||
crtPath = "./server.crt"
|
||||
keyPath = "./server.key"
|
||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||
# [proxies.plugin.autoTLS]
|
||||
# enable = true
|
||||
# email = "admin@example.com"
|
||||
# cacheDir = "./.autotls-cache"
|
||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||
# hostAllowList = ["test.yourdomain.com"]
|
||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
hostHeaderRewrite = "127.0.0.1"
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
|
||||
@@ -361,6 +381,15 @@ localAddr = "127.0.0.1:443"
|
||||
hostHeaderRewrite = "127.0.0.1"
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
|
||||
[[proxies]]
|
||||
name = "plugin_http2https_redirect"
|
||||
type = "http"
|
||||
customDomains = ["test.yourdomain.com"]
|
||||
[proxies.plugin]
|
||||
type = "http2https_redirect"
|
||||
# Optional. Defaults to 443. Set this if the HTTPS entry is exposed on a non-standard port.
|
||||
# httpsPort = 443
|
||||
|
||||
[[proxies]]
|
||||
name = "plugin_http2http"
|
||||
type = "tcp"
|
||||
@@ -380,6 +409,14 @@ type = "tls2raw"
|
||||
localAddr = "127.0.0.1:80"
|
||||
crtPath = "./server.crt"
|
||||
keyPath = "./server.key"
|
||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||
# [proxies.plugin.autoTLS]
|
||||
# enable = true
|
||||
# email = "admin@example.com"
|
||||
# cacheDir = "./.autotls-cache"
|
||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||
# hostAllowList = ["test.yourdomain.com"]
|
||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
[[proxies]]
|
||||
name = "secret_tcp"
|
||||
|
||||
@@ -40,6 +40,12 @@ transport.maxPoolCount = 5
|
||||
# If negative, keep-alive probes are disabled.
|
||||
# transport.tcpKeepalive = 7200
|
||||
|
||||
# proxyIdleTimeout specifies the maximum time in seconds that a proxied user connection
|
||||
# can stay open without any traffic in either direction before frps closes it.
|
||||
# It reclaims connections whose endpoints are still open at the TCP level but will never send data again.
|
||||
# By default, this value is 0, which disables the idle timeout.
|
||||
# transport.proxyIdleTimeout = 7200
|
||||
|
||||
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
|
||||
transport.tls.force = false
|
||||
|
||||
@@ -52,6 +58,12 @@ transport.tls.force = false
|
||||
vhostHTTPPort = 80
|
||||
vhostHTTPSPort = 443
|
||||
|
||||
# Port used in the Location header when redirecting HTTP requests to HTTPS for
|
||||
# proxies with httpRedirect enabled. Set it when browsers reach the HTTPS vhost
|
||||
# through a port mapping, so it differs from vhostHTTPSPort.
|
||||
# By default, this value is vhostHTTPSPort.
|
||||
# vhostHTTPSRedirectPort = 443
|
||||
|
||||
# Response header timeout(seconds) for vhost http server, default is 60s
|
||||
# vhostHTTPTimeout = 60
|
||||
|
||||
|
||||
18
go.mod
18
go.mod
@@ -4,6 +4,8 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/fatedier/golib v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -41,10 +43,16 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
@@ -53,6 +61,10 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
@@ -61,6 +73,7 @@ require (
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/templexxx/cpu v0.1.1 // indirect
|
||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
@@ -68,7 +81,9 @@ require (
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
@@ -82,3 +97,6 @@ require (
|
||||
|
||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
||||
|
||||
// Use the Lolia-FRP fork of golib: io.Join relays with adaptively sized buffers.
|
||||
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.0.0-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/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Lolia-FRP/golib v0.0.0-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/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
@@ -20,12 +36,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
@@ -70,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/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
@@ -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/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
|
||||
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -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/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||
@@ -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/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
@@ -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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
|
||||
@@ -18,7 +18,7 @@ rm -rf ./release/packages
|
||||
mkdir -p ./release/packages
|
||||
|
||||
os_all='linux windows darwin freebsd openbsd android'
|
||||
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
|
||||
arch_all='amd64 arm arm64'
|
||||
extra_all='_ hf'
|
||||
|
||||
cd ./release
|
||||
|
||||
@@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
|
||||
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
|
||||
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
|
||||
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
|
||||
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
|
||||
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
|
||||
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
||||
}
|
||||
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
||||
|
||||
@@ -49,7 +49,8 @@ type ClientCommonConfig struct {
|
||||
// STUN server to help penetrate NAT hole.
|
||||
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
||||
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
||||
// is "", the default DNS will be used.
|
||||
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
|
||||
// supported, e.g. "https://1.1.1.1/dns-query".
|
||||
DNSServer string `json:"dnsServer,omitempty"`
|
||||
// LoginFailExit controls whether or not the client should exit after a
|
||||
// failed login attempt. If false, the client will retry until a login
|
||||
|
||||
@@ -369,6 +369,12 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
|
||||
type HTTPSProxyConfig struct {
|
||||
ProxyBaseConfig
|
||||
DomainConfig
|
||||
|
||||
// HTTPRedirect requests frps to redirect plain HTTP requests for the
|
||||
// proxy's domains to their HTTPS endpoint. It only takes effect when
|
||||
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
|
||||
// proxy registered on the same domain.
|
||||
HTTPRedirect bool `json:"httpRedirect,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
@@ -376,6 +382,7 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
|
||||
m.CustomDomains = c.CustomDomains
|
||||
m.SubDomain = c.SubDomain
|
||||
m.HTTPRedirect = c.HTTPRedirect
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||
@@ -383,6 +390,7 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||
|
||||
c.CustomDomains = m.CustomDomains
|
||||
c.SubDomain = m.SubDomain
|
||||
c.HTTPRedirect = m.HTTPRedirect
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
||||
|
||||
@@ -16,6 +16,7 @@ package v1
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
@@ -24,29 +25,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PluginHTTP2HTTPS = "http2https"
|
||||
PluginHTTPProxy = "http_proxy"
|
||||
PluginHTTPS2HTTP = "https2http"
|
||||
PluginHTTPS2HTTPS = "https2https"
|
||||
PluginHTTP2HTTP = "http2http"
|
||||
PluginSocks5 = "socks5"
|
||||
PluginStaticFile = "static_file"
|
||||
PluginUnixDomainSocket = "unix_domain_socket"
|
||||
PluginTLS2Raw = "tls2raw"
|
||||
PluginVirtualNet = "virtual_net"
|
||||
PluginHTTP2HTTPS = "http2https"
|
||||
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
||||
PluginHTTPProxy = "http_proxy"
|
||||
PluginHTTPS2HTTP = "https2http"
|
||||
PluginHTTPS2HTTPS = "https2https"
|
||||
PluginHTTP2HTTP = "http2http"
|
||||
PluginSocks5 = "socks5"
|
||||
PluginStaticFile = "static_file"
|
||||
PluginUnixDomainSocket = "unix_domain_socket"
|
||||
PluginTLS2Raw = "tls2raw"
|
||||
PluginVirtualNet = "virtual_net"
|
||||
)
|
||||
|
||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
|
||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||
}
|
||||
|
||||
type ClientPluginOptions interface {
|
||||
@@ -80,6 +83,29 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
|
||||
return jsonx.Marshal(c.ClientPluginOptions)
|
||||
}
|
||||
|
||||
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
|
||||
// that terminate TLS locally.
|
||||
type AutoTLSOptions struct {
|
||||
Enable bool `json:"enable,omitempty"`
|
||||
// Contact email for certificate expiration and important notices.
|
||||
Email string `json:"email,omitempty"`
|
||||
// Directory used to cache ACME account and certificates.
|
||||
CacheDir string `json:"cacheDir,omitempty"`
|
||||
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
|
||||
CADirURL string `json:"caDirURL,omitempty"`
|
||||
// Restrict certificate issuance to the listed domains.
|
||||
HostAllowList []string `json:"hostAllowList,omitempty"`
|
||||
}
|
||||
|
||||
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
out.HostAllowList = slices.Clone(o.HostAllowList)
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTP2HTTPSPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
@@ -98,6 +124,21 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTP2HTTPSRedirectPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
HTTPSPort int `json:"httpsPort,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
|
||||
|
||||
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTPProxyPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
HTTPUser string `json:"httpUser,omitempty"`
|
||||
@@ -122,6 +163,7 @@ type HTTPS2HTTPPluginOptions struct {
|
||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||
@@ -135,6 +177,7 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
||||
out := *o
|
||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -146,6 +189,7 @@ type HTTPS2HTTPSPluginOptions struct {
|
||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||
@@ -159,6 +203,7 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
out := *o
|
||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -230,10 +275,11 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
|
||||
}
|
||||
|
||||
type TLS2RawPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *TLS2RawPluginOptions) Complete() {}
|
||||
@@ -243,6 +289,7 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ type ServerConfig struct {
|
||||
// Vhost requests. If this value is 0, the server will not listen for HTTPS
|
||||
// requests.
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
|
||||
// VhostHTTPSRedirectPort specifies the port used in the Location header
|
||||
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
|
||||
// enabled. Set it when browsers reach the HTTPS vhost through a port
|
||||
// mapping, so it differs from VhostHTTPSPort. By default, this value is
|
||||
// VhostHTTPSPort.
|
||||
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
|
||||
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
|
||||
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
|
||||
// requests on one single port. If it's not - it will listen on this value for
|
||||
@@ -118,6 +124,7 @@ func (c *ServerConfig) Complete() error {
|
||||
}
|
||||
|
||||
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
||||
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
|
||||
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
|
||||
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||
@@ -173,6 +180,12 @@ type ServerTransportConfig struct {
|
||||
// before terminating the connection. It is not recommended to change this
|
||||
// value. By default, this value is 90. Set negative value to disable it.
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
|
||||
// ProxyIdleTimeout specifies the maximum time in seconds that a proxied
|
||||
// user connection can stay open without any traffic in either direction
|
||||
// before frps closes it. This reclaims connections whose endpoints are
|
||||
// still open at the TCP level but will never send data again. By default,
|
||||
// this value is 0, which disables the idle timeout.
|
||||
ProxyIdleTimeout int64 `json:"proxyIdleTimeout,omitempty"`
|
||||
// QUIC options.
|
||||
QUIC QUICOptions `json:"quic,omitempty"`
|
||||
// TLS specifies TLS settings for the connection from the client.
|
||||
|
||||
@@ -16,6 +16,8 @@ package validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
@@ -24,6 +26,8 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
|
||||
switch v := c.(type) {
|
||||
case *v1.HTTP2HTTPSPluginOptions:
|
||||
return validateHTTP2HTTPSPluginOptions(v)
|
||||
case *v1.HTTP2HTTPSRedirectPluginOptions:
|
||||
return validateHTTP2HTTPSRedirectPluginOptions(v)
|
||||
case *v1.HTTPS2HTTPPluginOptions:
|
||||
return validateHTTPS2HTTPPluginOptions(v)
|
||||
case *v1.HTTPS2HTTPSPluginOptions:
|
||||
@@ -45,10 +49,17 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
|
||||
return ValidatePort(c.HTTPSPort, "httpsPort")
|
||||
}
|
||||
|
||||
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
||||
if c.LocalAddr == "" {
|
||||
return errors.New("localAddr is required")
|
||||
}
|
||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,6 +67,9 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
|
||||
if c.LocalAddr == "" {
|
||||
return errors.New("localAddr is required")
|
||||
}
|
||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,5 +91,29 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
|
||||
if c.LocalAddr == "" {
|
||||
return errors.New("localAddr is required")
|
||||
}
|
||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
|
||||
if c == nil || !c.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if crtPath != "" || keyPath != "" {
|
||||
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
|
||||
}
|
||||
if strings.TrimSpace(c.CacheDir) == "" {
|
||||
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
|
||||
}
|
||||
if len(c.HostAllowList) > 0 {
|
||||
for _, host := range c.HostAllowList {
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,8 +243,9 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
filterAll := proxyType == "" || proxyType == "all"
|
||||
for name, proxyStats := range m.info.ProxyStatistics {
|
||||
if proxyStats.ProxyType != proxyType {
|
||||
if !filterAll && proxyStats.ProxyType != proxyType {
|
||||
continue
|
||||
}
|
||||
res = append(res, toProxyStats(name, proxyStats))
|
||||
@@ -257,8 +258,13 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
||||
defer m.mu.Unlock()
|
||||
|
||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||
if ok && proxyStats.ProxyType == proxyType {
|
||||
res = toProxyStats(proxyName, proxyStats)
|
||||
if ok {
|
||||
// filterAll allows the "all proxies" API to look up a proxy by name without
|
||||
// knowing its type (proxyType == "" or "all").
|
||||
filterAll := proxyType == "" || proxyType == "all"
|
||||
if filterAll || proxyStats.ProxyType == proxyType {
|
||||
res = toProxyStats(proxyName, proxyStats)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ type NewProxy struct {
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
||||
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
||||
HTTPRedirect bool `json:"http_redirect,omitempty"`
|
||||
|
||||
// stcp, sudp, xtcp
|
||||
Sk string `json:"sk,omitempty"`
|
||||
|
||||
@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
|
||||
func TestNewReadWriter(t *testing.T) {
|
||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
|
||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
|
||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
|
||||
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
|
||||
}
|
||||
|
||||
func TestNewReadWriterEncoding(t *testing.T) {
|
||||
for _, wireProtocol := range []string{"", wire.ProtocolV1} {
|
||||
var legacy bytes.Buffer
|
||||
legacyRW := NewReadWriter(&legacy, wireProtocol)
|
||||
require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
|
||||
require.NotEmpty(t, legacy.Bytes())
|
||||
require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
|
||||
}
|
||||
|
||||
var v2 bytes.Buffer
|
||||
v2RW := NewReadWriter(&v2, wire.ProtocolV2)
|
||||
require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
|
||||
frame, err := wire.NewConn(&v2).ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
}
|
||||
|
||||
func TestV2MessageTypeIDsAreStable(t *testing.T) {
|
||||
require.Equal(t, uint16(1), V2TypeLogin)
|
||||
require.Equal(t, uint16(2), V2TypeLoginResp)
|
||||
|
||||
@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
|
||||
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
||||
vResp, cResp, err := c.analysis(session)
|
||||
if err != nil {
|
||||
log.Debugf("sid [%s] analysis error: %v", err)
|
||||
log.Debugf("sid [%s] analysis error: %v", sid, err)
|
||||
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
||||
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
||||
}
|
||||
@@ -385,7 +385,6 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
||||
if !isLast {
|
||||
return nil
|
||||
}
|
||||
ports := make([]msg.PortsRange, 0, 1)
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -394,9 +393,8 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ports = append(ports, msg.PortsRange{
|
||||
return []msg.PortsRange{{
|
||||
From: max(port-difference-5, port-maxNumber, 1),
|
||||
To: min(port+difference+5, port+maxNumber, 65535),
|
||||
})
|
||||
return ports
|
||||
}}
|
||||
}
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
stdlog "log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -80,11 +81,27 @@ func newHTTPSBridgePluginServer(
|
||||
enableHTTP2 *bool,
|
||||
useSourceRemoteAddr bool,
|
||||
) (*httpBridgePlugin, error) {
|
||||
listener := NewProxyListener()
|
||||
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
|
||||
}
|
||||
|
||||
// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
|
||||
// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
|
||||
func newHTTPSBridgePluginServerWithTLSConfig(
|
||||
handler http.Handler,
|
||||
tlsConfig *tls.Config,
|
||||
enableHTTP2 *bool,
|
||||
useSourceRemoteAddr bool,
|
||||
) *httpBridgePlugin {
|
||||
server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
|
||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
|
||||
}
|
||||
|
||||
func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
|
||||
listener := NewProxyListener()
|
||||
p := &httpBridgePlugin{
|
||||
l: listener,
|
||||
s: server,
|
||||
@@ -93,7 +110,7 @@ func newHTTPSBridgePluginServer(
|
||||
go func() {
|
||||
_ = p.s.ServeTLS(listener, "", "")
|
||||
}()
|
||||
return p, nil
|
||||
return p
|
||||
}
|
||||
|
||||
func newHTTPBridgeReverseProxy(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httputil"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
@@ -32,7 +33,7 @@ type HTTPS2HTTPPlugin struct {
|
||||
*httpBridgePlugin
|
||||
}
|
||||
|
||||
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||
|
||||
p := &HTTPS2HTTPPlugin{
|
||||
@@ -49,6 +50,15 @@ func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi
|
||||
nil,
|
||||
)
|
||||
|
||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
||||
}
|
||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -18,6 +18,7 @@ package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
|
||||
@@ -34,7 +35,7 @@ type HTTPS2HTTPSPlugin struct {
|
||||
*httpBridgePlugin
|
||||
}
|
||||
|
||||
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||
|
||||
p := &HTTPS2HTTPSPlugin{
|
||||
@@ -55,6 +56,15 @@ func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plug
|
||||
tr,
|
||||
)
|
||||
|
||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
||||
}
|
||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -33,7 +33,12 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||
}
|
||||
return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
|
||||
}
|
||||
|
||||
// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
|
||||
// It is used by features such as autoTLS that supply their own certificate provider.
|
||||
func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
|
||||
server := &http.Server{
|
||||
Handler: withMisdirectedRequestCheck(handler),
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
@@ -42,7 +47,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
||||
if !lo.FromPtr(enableHTTP2) {
|
||||
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
||||
}
|
||||
return server, nil
|
||||
return server
|
||||
}
|
||||
|
||||
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
type PluginContext struct {
|
||||
Name string
|
||||
HostAllowList []string
|
||||
VnetController *vnet.Controller
|
||||
}
|
||||
|
||||
|
||||
@@ -39,16 +39,25 @@ type TLS2RawPlugin struct {
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.TLS2RawPluginOptions)
|
||||
|
||||
p := &TLS2RawPlugin{
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var tlsConfig *tls.Config
|
||||
var err error
|
||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
p.tlsConfig = tlsConfig
|
||||
return p, nil
|
||||
|
||||
@@ -17,13 +17,13 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goliblog "github.com/fatedier/golib/log"
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||
@@ -39,8 +39,8 @@ type testPlugin struct {
|
||||
var logCaptureMu sync.Mutex
|
||||
|
||||
type logCapture struct {
|
||||
bytes.Buffer
|
||||
levels []goliblog.Level
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (p testPlugin) Name() string {
|
||||
@@ -55,9 +55,36 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
|
||||
return p.handler(ctx, op, content)
|
||||
}
|
||||
|
||||
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
||||
w.levels = append(w.levels, level)
|
||||
return w.Write(p)
|
||||
func (w *logCapture) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func (w *logCapture) String() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.String()
|
||||
}
|
||||
|
||||
// levels parses the captured JSON log output and returns the level of each entry.
|
||||
func (w *logCapture) levels() []charmlog.Level {
|
||||
var levels []charmlog.Level
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(w.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var entry struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
|
||||
levels = append(levels, lvl)
|
||||
}
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
func captureLogOutput(t *testing.T) *logCapture {
|
||||
@@ -66,11 +93,10 @@ func captureLogOutput(t *testing.T) *logCapture {
|
||||
logCaptureMu.Lock()
|
||||
logOutput := &logCapture{}
|
||||
oldLogger := frplog.Logger
|
||||
frplog.Logger = goliblog.New(
|
||||
goliblog.WithOutput(logOutput),
|
||||
goliblog.WithLevel(goliblog.TraceLevel),
|
||||
goliblog.WithCaller(false),
|
||||
)
|
||||
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
|
||||
Level: charmlog.DebugLevel,
|
||||
Formatter: charmlog.JSONFormatter,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
frplog.Logger = oldLogger
|
||||
logCaptureMu.Unlock()
|
||||
@@ -263,10 +289,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op string
|
||||
level goliblog.Level
|
||||
level charmlog.Level
|
||||
}{
|
||||
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
||||
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
||||
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
|
||||
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -288,8 +314,9 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
||||
levels := logOutput.levels()
|
||||
if len(levels) != 1 || levels[0] != tt.level {
|
||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -325,12 +352,13 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
||||
t.Fatalf("missing aggregated errors: %v", err)
|
||||
}
|
||||
if len(logOutput.levels) != 2 {
|
||||
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
||||
levels := logOutput.levels()
|
||||
if len(levels) != 2 {
|
||||
t.Fatalf("expected two warning logs, got %v", levels)
|
||||
}
|
||||
for _, level := range logOutput.levels {
|
||||
if level != goliblog.WarnLevel {
|
||||
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
||||
for _, level := range levels {
|
||||
if level != charmlog.WarnLevel {
|
||||
t.Fatalf("expected warning log level, got %v", levels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type V2Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// APIHandler is a handler function that returns a response object or an error.
|
||||
type APIHandler func(ctx *Context) (any, error)
|
||||
|
||||
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
||||
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := NewContext(w, r)
|
||||
res, err := handler(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||
code := http.StatusInternalServerError
|
||||
if e, ok := err.(*Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,18 @@ package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/log"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
var (
|
||||
TraceLevel = log.TraceLevel
|
||||
TraceLevel = log.DebugLevel
|
||||
DebugLevel = log.DebugLevel
|
||||
InfoLevel = log.InfoLevel
|
||||
WarnLevel = log.WarnLevel
|
||||
@@ -32,39 +37,157 @@ var (
|
||||
var Logger *log.Logger
|
||||
|
||||
func init() {
|
||||
Logger = log.New(
|
||||
log.WithCaller(true),
|
||||
log.AddCallerSkip(1),
|
||||
log.WithLevel(log.InfoLevel),
|
||||
)
|
||||
Logger = log.NewWithOptions(os.Stderr, log.Options{
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "LoliaFRP-CLI",
|
||||
CallerOffset: 1,
|
||||
})
|
||||
// 设置自定义样式以支持 Trace 级别
|
||||
styles := log.DefaultStyles()
|
||||
styles.Levels[TraceLevel] = lipgloss.NewStyle().
|
||||
SetString("TRACE").
|
||||
Bold(true).
|
||||
MaxWidth(5).
|
||||
Foreground(lipgloss.Color("61"))
|
||||
Logger.SetStyles(styles)
|
||||
}
|
||||
|
||||
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
||||
options := []log.Option{}
|
||||
var output io.Writer
|
||||
var err error
|
||||
|
||||
if logPath == "console" {
|
||||
if !disableLogColor {
|
||||
options = append(options,
|
||||
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
|
||||
Colorful: true,
|
||||
}, os.Stdout)),
|
||||
)
|
||||
}
|
||||
output = os.Stdout
|
||||
} else {
|
||||
writer := log.NewRotateFileWriter(log.RotateFileConfig{
|
||||
FileName: logPath,
|
||||
Mode: log.RotateFileModeDaily,
|
||||
MaxDays: maxDays,
|
||||
})
|
||||
writer.Init()
|
||||
options = append(options, log.WithOutput(writer))
|
||||
// Use rotating file writer
|
||||
output, err = NewRotateFileWriter(logPath, maxDays)
|
||||
if err != nil {
|
||||
// Fallback to console if file creation fails
|
||||
output = os.Stdout
|
||||
}
|
||||
}
|
||||
|
||||
level, err := log.ParseLevel(levelStr)
|
||||
if err != nil {
|
||||
level = log.InfoLevel
|
||||
}
|
||||
options = append(options, log.WithLevel(level))
|
||||
Logger = Logger.WithOptions(options...)
|
||||
|
||||
Logger = log.NewWithOptions(output, log.Options{
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "LoliaFRP-CLI",
|
||||
CallerOffset: 1,
|
||||
Level: level,
|
||||
})
|
||||
}
|
||||
|
||||
// NewRotateFileWriter creates a rotating file writer
|
||||
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
|
||||
w := &RotateFileWriter{
|
||||
filePath: filePath,
|
||||
maxDays: maxDays,
|
||||
lastRotate: time.Now(),
|
||||
currentDate: time.Now().Format("2006-01-02"),
|
||||
}
|
||||
|
||||
if err := w.openFile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// RotateFileWriter implements io.Writer with daily rotation
|
||||
type RotateFileWriter struct {
|
||||
filePath string
|
||||
maxDays int
|
||||
file *os.File
|
||||
lastRotate time.Time
|
||||
currentDate string
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) openFile() error {
|
||||
var err error
|
||||
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) checkRotate() error {
|
||||
now := time.Now()
|
||||
currentDate := now.Format("2006-01-02")
|
||||
|
||||
if currentDate != w.currentDate {
|
||||
// Close current file
|
||||
if w.file != nil {
|
||||
w.file.Close()
|
||||
}
|
||||
|
||||
// Rename current file with date suffix
|
||||
oldPath := w.filePath
|
||||
newPath := w.filePath + "." + w.currentDate
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old log files
|
||||
w.cleanupOldLogs(now)
|
||||
|
||||
// Update current date and open new file
|
||||
w.currentDate = currentDate
|
||||
w.lastRotate = now
|
||||
return w.openFile()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
|
||||
if w.maxDays <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cutoffDate := now.AddDate(0, 0, -w.maxDays)
|
||||
|
||||
// Find and remove old log files
|
||||
dir := filepath.Dir(w.filePath)
|
||||
base := filepath.Base(w.filePath)
|
||||
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := f.Name()
|
||||
// Extract date from filename (base.YYYY-MM-DD)
|
||||
if dateStr, ok := strings.CutPrefix(name, base+"."); ok {
|
||||
if len(dateStr) == 10 {
|
||||
fileDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err == nil && fileDate.Before(cutoffDate) {
|
||||
os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) Write(p []byte) (n int, err error) {
|
||||
if err := w.checkRotate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.file.Write(p)
|
||||
}
|
||||
|
||||
func (w *RotateFileWriter) Close() error {
|
||||
if w.file != nil {
|
||||
return w.file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Errorf(format string, v ...any) {
|
||||
@@ -75,6 +198,10 @@ func Warnf(format string, v ...any) {
|
||||
Logger.Warnf(format, v...)
|
||||
}
|
||||
|
||||
func Info(format string, v ...any) {
|
||||
Logger.Info(format, v...)
|
||||
}
|
||||
|
||||
func Infof(format string, v ...any) {
|
||||
Logger.Infof(format, v...)
|
||||
}
|
||||
@@ -84,11 +211,12 @@ func Debugf(format string, v ...any) {
|
||||
}
|
||||
|
||||
func Tracef(format string, v ...any) {
|
||||
Logger.Tracef(format, v...)
|
||||
Logger.Logf(TraceLevel, format, v...)
|
||||
}
|
||||
|
||||
func Logf(level log.Level, offset int, format string, v ...any) {
|
||||
Logger.Logf(level, offset, format, v...)
|
||||
// charmbracelet/log doesn't support offset, so we ignore it
|
||||
Logger.Logf(level, format, v...)
|
||||
}
|
||||
|
||||
type WriteLogger struct {
|
||||
@@ -104,6 +232,8 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
|
||||
}
|
||||
|
||||
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
||||
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
|
||||
// charmbracelet/log doesn't support offset in Log
|
||||
msg := string(bytes.TrimRight(p, "\n"))
|
||||
Logger.Log(w.level, msg)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@ package net
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SetDefaultDNSAddress(dnsAddress string) {
|
||||
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
|
||||
if strings.HasPrefix(dnsAddress, "https://") {
|
||||
SetDefaultDNSOverHTTPS(dnsAddress)
|
||||
return
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
||||
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
var version = "0.69.0"
|
||||
var version = "LoliaFRP-CLI 0.69.3"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -39,11 +39,15 @@ var ErrNoRouteFound = errors.New("no route found")
|
||||
|
||||
type HTTPReverseProxyOptions struct {
|
||||
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 {
|
||||
proxy http.Handler
|
||||
vhostRouter *Routers
|
||||
proxy http.Handler
|
||||
vhostRouter *Routers
|
||||
httpsRedirector *HTTPSRedirector
|
||||
|
||||
responseHeaderTimeout time.Duration
|
||||
}
|
||||
@@ -55,6 +59,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
|
||||
rp := &HTTPReverseProxy{
|
||||
responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,
|
||||
vhostRouter: vhostRouter,
|
||||
httpsRedirector: option.HTTPSRedirector,
|
||||
}
|
||||
proxy := &httputil.ReverseProxy{
|
||||
// Modify incoming requests by route policies.
|
||||
@@ -281,6 +286,12 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request)
|
||||
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 {
|
||||
rp.connectHandler(rw, newreq)
|
||||
} 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 (
|
||||
NotFound = `<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>Not Found</title>
|
||||
<style>
|
||||
body {
|
||||
width: 35em;
|
||||
margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - 未绑定域名</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, sans-serif;
|
||||
display: flex;
|
||||
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>
|
||||
<body>
|
||||
<h1>The page you requested was not found.</h1>
|
||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||
Please try again later.</p>
|
||||
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
|
||||
<p><em>Faithfully yours, frp.</em></p>
|
||||
<div class="container">
|
||||
<h1>域名未绑定</h1>
|
||||
<p>这个域名还没有绑定到任何隧道哦 (;д;)</p>
|
||||
<p><strong>可能是这些原因:</strong></p>
|
||||
<ul>
|
||||
<li>域名配置不对,或者没有正确解析</li>
|
||||
<li>隧道可能还没启动,或者已经停止</li>
|
||||
<li>自定义域名忘记在服务端配置了</li>
|
||||
</ul>
|
||||
<div class="footer">由 <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -69,7 +116,7 @@ func getNotFoundPageContent() []byte {
|
||||
|
||||
func NotFoundResponse() *http.Response {
|
||||
header := make(http.Header)
|
||||
header.Set("server", "frp/"+version.Full())
|
||||
header.Set("server", version.Full())
|
||||
header.Set("Content-Type", "text/html")
|
||||
|
||||
content := getNotFoundPageContent()
|
||||
|
||||
@@ -112,5 +112,5 @@ func (l *Logger) Debugf(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())
|
||||
}
|
||||
|
||||
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
|
||||
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
|
||||
|
||||
// apis
|
||||
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}/{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/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).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/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
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
subRouter.PathPrefix("/static/").Handler(
|
||||
|
||||
@@ -92,6 +92,51 @@ func (cm *ControlManager) Close() error {
|
||||
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.
|
||||
type SessionContext struct {
|
||||
// all resource managers and controllers
|
||||
@@ -112,6 +157,8 @@ type SessionContext struct {
|
||||
ServerCfg *v1.ServerConfig
|
||||
// client registry
|
||||
ClientRegistry *registry.ClientRegistry
|
||||
// negotiated wire protocol for this client session
|
||||
WireProtocol string
|
||||
}
|
||||
|
||||
type Control struct {
|
||||
@@ -452,6 +499,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
|
||||
Configurer: pxyConf,
|
||||
ServerCfg: ctl.sessionCtx.ServerCfg,
|
||||
EncryptionKey: ctl.sessionCtx.EncryptionKey,
|
||||
WireProtocol: ctl.sessionCtx.WireProtocol,
|
||||
})
|
||||
if err != nil {
|
||||
return remoteAddr, err
|
||||
|
||||
@@ -50,6 +50,9 @@ type ResourceController struct {
|
||||
// For HTTP proxies, forwarding HTTP requests
|
||||
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
|
||||
VhostHTTPSMuxer *vhost.HTTPSMuxer
|
||||
|
||||
|
||||
@@ -38,21 +38,28 @@ type Controller struct {
|
||||
serverCfg *v1.ServerConfig
|
||||
clientRegistry *registry.ClientRegistry
|
||||
pxyManager ProxyManager
|
||||
ctlManager ControlManager
|
||||
}
|
||||
|
||||
type ProxyManager interface {
|
||||
GetByName(name string) (proxy.Proxy, bool)
|
||||
}
|
||||
|
||||
type ControlManager interface {
|
||||
CloseAllProxyByName(proxyName string) error
|
||||
}
|
||||
|
||||
func NewController(
|
||||
serverCfg *v1.ServerConfig,
|
||||
clientRegistry *registry.ClientRegistry,
|
||||
pxyManager ProxyManager,
|
||||
ctlManager ControlManager,
|
||||
) *Controller {
|
||||
return &Controller{
|
||||
serverCfg: serverCfg,
|
||||
clientRegistry: clientRegistry,
|
||||
pxyManager: pxyManager,
|
||||
ctlManager: ctlManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +225,24 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
|
||||
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
|
||||
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||
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.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)
|
||||
metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||
workConn = netpkg.WrapCloseNotifyConn(workConn, func(error) {
|
||||
pxy.updateStatsAfterClosedConn()
|
||||
})
|
||||
metrics.Server.OpenConnection(name, proxyType)
|
||||
return
|
||||
}
|
||||
|
||||
func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {
|
||||
func (pxy *HTTPProxy) updateStatsAfterClosedConn() {
|
||||
name := pxy.GetName()
|
||||
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
||||
metrics.Server.CloseConnection(name, proxyType)
|
||||
metrics.Server.AddTrafficIn(name, proxyType, totalWrite)
|
||||
metrics.Server.AddTrafficOut(name, proxyType, totalRead)
|
||||
}
|
||||
|
||||
func (pxy *HTTPProxy) Close() {
|
||||
|
||||
@@ -31,6 +31,9 @@ func init() {
|
||||
type HTTPSProxy struct {
|
||||
*BaseProxy
|
||||
cfg *v1.HTTPSProxyConfig
|
||||
|
||||
// domains registered for HTTP to HTTPS redirection, removed on Close
|
||||
redirectDomains []string
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
remoteAddr = strings.Join(addrs, ",")
|
||||
return
|
||||
}
|
||||
|
||||
func (pxy *HTTPSProxy) Close() {
|
||||
for _, domain := range pxy.redirectDomains {
|
||||
pxy.rc.HTTPSRedirector.Remove(domain)
|
||||
}
|
||||
pxy.redirectDomains = nil
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
"github.com/fatedier/frp/pkg/util/limit"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
@@ -92,6 +94,7 @@ type BaseProxy struct {
|
||||
userInfo plugin.UserInfo
|
||||
loginMsg *msg.Login
|
||||
configurer v1.ProxyConfigurer
|
||||
wireProtocol string
|
||||
|
||||
mu sync.RWMutex
|
||||
xl *xlog.Logger
|
||||
@@ -314,14 +317,175 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
||||
|
||||
name := pxy.GetName()
|
||||
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)
|
||||
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.AddTrafficIn(name, proxyType, inCount)
|
||||
metrics.Server.AddTrafficOut(name, proxyType, outCount)
|
||||
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 {
|
||||
UserInfo plugin.UserInfo
|
||||
LoginMsg *msg.Login
|
||||
@@ -331,6 +495,7 @@ type Options struct {
|
||||
Configurer v1.ProxyConfigurer
|
||||
ServerCfg *v1.ServerConfig
|
||||
EncryptionKey []byte
|
||||
WireProtocol string
|
||||
}
|
||||
|
||||
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,
|
||||
loginMsg: options.LoginMsg,
|
||||
configurer: configurer,
|
||||
wireProtocol: options.WireProtocol,
|
||||
}
|
||||
|
||||
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
|
||||
|
||||
@@ -15,12 +15,15 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
||||
@@ -51,3 +54,56 @@ func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
||||
require.NoError(t, result.err)
|
||||
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)
|
||||
|
||||
// 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 {
|
||||
var (
|
||||
rawMsg msg.Message
|
||||
@@ -116,10 +116,10 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
)
|
||||
xl.Tracef("loop waiting message from udp workConn")
|
||||
// client will send heartbeat in workConn for keeping alive
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
||||
_ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
||||
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||
_ = conn.Close()
|
||||
_ = payloadConn.Close()
|
||||
// notify proxy to start a new work connection
|
||||
// ignore error here, it means the proxy is closed
|
||||
_ = errors.PanicToError(func() {
|
||||
@@ -127,7 +127,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
})
|
||||
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)
|
||||
}
|
||||
switch m := rawMsg.(type) {
|
||||
@@ -144,7 +144,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
int64(len(m.Content)),
|
||||
)
|
||||
}); errRet != nil {
|
||||
conn.Close()
|
||||
_ = payloadConn.Close()
|
||||
xl.Infof("reader goroutine for udp work connection closed")
|
||||
return
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
}
|
||||
|
||||
// send message to workConn
|
||||
workConnSenderFn := func(conn net.Conn, ctx context.Context) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn, ctx context.Context) {
|
||||
var errRet error
|
||||
for {
|
||||
select {
|
||||
@@ -162,9 +162,9 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
xl.Infof("sender goroutine for udp work connection closed")
|
||||
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)
|
||||
conn.Close()
|
||||
_ = payloadConn.Close()
|
||||
return
|
||||
}
|
||||
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)
|
||||
// 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())
|
||||
go workConnReaderFn(pxy.workConn)
|
||||
go workConnSenderFn(pxy.workConn, ctx)
|
||||
go workConnReaderFn(payloadConn)
|
||||
go workConnSenderFn(payloadConn, ctx)
|
||||
_, ok := <-pxy.checkCloseCh
|
||||
cancel()
|
||||
if !ok {
|
||||
|
||||
@@ -16,6 +16,7 @@ package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
@@ -73,10 +74,7 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
||||
if errRet != nil {
|
||||
continue
|
||||
}
|
||||
m := &msg.NatHoleSid{
|
||||
Sid: sid,
|
||||
}
|
||||
errRet = msg.WriteMsg(workConn, m)
|
||||
errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
|
||||
if errRet != nil {
|
||||
xl.Warnf("write nat hole sid package error, %v", errRet)
|
||||
}
|
||||
@@ -87,6 +85,13 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
||||
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() {
|
||||
pxy.closeOnce.Do(func() {
|
||||
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.
|
||||
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{
|
||||
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
|
||||
HTTPSRedirector: redirector,
|
||||
}, svr.httpVhostRouter)
|
||||
svr.rc.HTTPReverseProxy = rp
|
||||
|
||||
@@ -508,7 +514,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
||||
conn.Close()
|
||||
}
|
||||
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)
|
||||
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
|
||||
ProxyName: m.ProxyName,
|
||||
@@ -777,6 +783,7 @@ func (svr *Service) RegisterControl(
|
||||
LoginMsg: loginMsg,
|
||||
ServerCfg: svr.cfg,
|
||||
ClientRegistry: svr.clientRegistry,
|
||||
WireProtocol: wireProtocol,
|
||||
})
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
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 := ""
|
||||
// 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.
|
||||
@@ -844,5 +851,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
|
||||
visitorUser = ctl.sessionCtx.LoginMsg.User
|
||||
}
|
||||
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,
|
||||
useEncryption bool, useCompression bool, visitorUser string,
|
||||
wireProtocol string,
|
||||
) (err error) {
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
@@ -90,7 +91,11 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
||||
if useCompression {
|
||||
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 {
|
||||
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
||||
return
|
||||
@@ -98,6 +103,15 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
||||
return
|
||||
}
|
||||
|
||||
type wireProtocolConn struct {
|
||||
net.Conn
|
||||
wireProtocol string
|
||||
}
|
||||
|
||||
func (c *wireProtocolConn) WireProtocol() string {
|
||||
return c.wireProtocol
|
||||
}
|
||||
|
||||
func (vm *Manager) CloseListener(name string) {
|
||||
vm.mu.Lock()
|
||||
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"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -158,7 +160,7 @@ webServer.port = %d
|
||||
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")
|
||||
clientConf := tcpClientConfig("tcp", portName, `
|
||||
transport.wireProtocol = "v2"
|
||||
@@ -170,6 +172,21 @@ transport.wireProtocol = "v2"
|
||||
consts.DefaultServerConfig,
|
||||
[]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)
|
||||
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 {
|
||||
ClientID string `json:"clientID"`
|
||||
WireProtocol string `json:"wireProtocol"`
|
||||
|
||||
@@ -98,6 +98,73 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
|
||||
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() {
|
||||
webPort := f.AllocPort()
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { http } from './http'
|
||||
import type { ClientInfoData } from '../types/client'
|
||||
import { buildQueryString, http } from './http'
|
||||
import type { V2Page } from './http'
|
||||
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
||||
|
||||
export const getClients = () => {
|
||||
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) => {
|
||||
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> {
|
||||
const defaultOptions: RequestInit = {
|
||||
credentials: 'include',
|
||||
@@ -34,9 +49,55 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
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 = {
|
||||
get: <T>(url: string, options?: RequestInit) =>
|
||||
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) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { http } from './http'
|
||||
import { buildQueryString, http } from './http'
|
||||
import type { V2Page } from './http'
|
||||
import type {
|
||||
GetProxyResponse,
|
||||
ProxyListV2Params,
|
||||
ProxyStatsInfo,
|
||||
ProxyV2Info,
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
@@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => {
|
||||
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) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||
}
|
||||
|
||||
@@ -13,3 +13,13 @@ export interface ClientInfoData {
|
||||
disconnectedAt?: number
|
||||
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 {
|
||||
name: string
|
||||
type?: string
|
||||
conf: any
|
||||
user: string
|
||||
clientID: string
|
||||
@@ -15,6 +16,34 @@ export interface GetProxyResponse {
|
||||
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 {
|
||||
name: string
|
||||
trafficIn: number[]
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
>
|
||||
<span class="status-dot" :class="tab.value"></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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,9 +35,9 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="client in filteredClients"
|
||||
v-for="client in clients"
|
||||
:key="client.key"
|
||||
:client="client"
|
||||
/>
|
||||
@@ -44,82 +46,123 @@
|
||||
<el-empty description="No clients found" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { Client } from '../utils/client'
|
||||
import ClientCard from '../components/ClientCard.vue'
|
||||
import { getClients } from '../api/client'
|
||||
import { getClientsV2 } from '../api/client'
|
||||
|
||||
const clients = ref<Client[]>([])
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = clients.value.length
|
||||
const online = clients.value.filter((c) => c.online).length
|
||||
const offline = total - online
|
||||
return { total, online, offline }
|
||||
})
|
||||
let searchDebounceTimer: number | null = null
|
||||
let requestSeq = 0
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
||||
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
||||
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
||||
{
|
||||
value: 'all' as const,
|
||||
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(() => {
|
||||
let result = clients.value
|
||||
|
||||
// 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
|
||||
const fetchData = async (silent = false) => {
|
||||
const seq = ++requestSeq
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const json = await getClients()
|
||||
clients.value = json.map((data) => new Client(data))
|
||||
const data = await getClientsV2({
|
||||
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) {
|
||||
if (seq !== requestSeq) return
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Failed to fetch clients: ' + error.message,
|
||||
type: 'error',
|
||||
})
|
||||
} 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 = () => {
|
||||
refreshTimer = window.setInterval(() => {
|
||||
fetchData()
|
||||
fetchData(true)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
resetPageAndFetch()
|
||||
})
|
||||
|
||||
watch(searchText, () => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
fetchData()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
startAutoRefresh()
|
||||
@@ -137,6 +193,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
clearSearchDebounce()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -274,6 +331,11 @@ onUnmounted(() => {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
html.dark .status-tab {
|
||||
background: var(--el-bg-color-overlay);
|
||||
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
|
||||
.status-tab {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<ActionButton variant="outline" size="small" @click="fetchData">
|
||||
<ActionButton variant="outline" size="small" @click="refreshData">
|
||||
Refresh
|
||||
</ActionButton>
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="proxy in filteredProxies"
|
||||
v-for="proxy in proxies"
|
||||
:key="`${proxy.type}:${proxy.name}`"
|
||||
:proxy="proxy"
|
||||
:show-type="activeType === 'all'"
|
||||
@@ -87,6 +87,18 @@
|
||||
</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
|
||||
v-model="showClearDialog"
|
||||
title="Clear Offline"
|
||||
@@ -99,9 +111,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
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 ActionButton from '@shared/components/ActionButton.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 PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||
import {
|
||||
getProxiesByType,
|
||||
getProxiesV2,
|
||||
clearOfflineProxies as apiClearOfflineProxies,
|
||||
} from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import { getClients } from '../api/client'
|
||||
import { getClientsV2 } from '../api/client'
|
||||
import { Client } from '../utils/client'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -149,6 +162,12 @@ const searchText = ref('')
|
||||
const showClearDialog = ref(false)
|
||||
const clientIDFilter = ref((route.query.clientID 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(() => {
|
||||
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) => {
|
||||
if (key) {
|
||||
const client = clientOptions.value.find((c) => c.key === key)
|
||||
@@ -263,122 +230,174 @@ const onClientFilterChange = (key: string) => {
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const json = await getClients()
|
||||
clients.value = json.map((data) => new Client(data))
|
||||
} catch {
|
||||
// Ignore errors when fetching clients
|
||||
const allClients: Client[] = []
|
||||
let nextPage = 1
|
||||
let totalClients = 0
|
||||
|
||||
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
|
||||
let serverInfo: {
|
||||
// Server info cache - cache the Promise itself so concurrent first calls
|
||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||
type ServerInfoLite = {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
}
|
||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
// Allow retry after failure
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
}
|
||||
|
||||
const convertProxies = async (
|
||||
type: string,
|
||||
json: any,
|
||||
): Promise<BaseProxy[]> => {
|
||||
const convertProxy = async (
|
||||
proxy: ProxyStatsInfo,
|
||||
): Promise<BaseProxy | null> => {
|
||||
const type = proxy.type || activeType.value
|
||||
if (type === 'tcp') {
|
||||
return json.proxies.map((p: any) => new TCPProxy(p))
|
||||
return new TCPProxy(proxy)
|
||||
}
|
||||
if (type === 'udp') {
|
||||
return json.proxies.map((p: any) => new UDPProxy(p))
|
||||
return new UDPProxy(proxy)
|
||||
}
|
||||
if (type === 'http') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.vhostHTTPPort) {
|
||||
return json.proxies.map(
|
||||
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||
)
|
||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||
}
|
||||
return []
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.vhostHTTPSPort) {
|
||||
return json.proxies.map(
|
||||
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||
)
|
||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||
}
|
||||
return []
|
||||
return null
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.tcpmuxHTTPConnectPort) {
|
||||
return json.proxies.map(
|
||||
(p: any) =>
|
||||
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
}
|
||||
return []
|
||||
return null
|
||||
}
|
||||
if (type === 'stcp') {
|
||||
return json.proxies.map((p: any) => new STCPProxy(p))
|
||||
return new STCPProxy(proxy)
|
||||
}
|
||||
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
|
||||
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||
return json.proxies.map((p: any) => {
|
||||
const bp = new BaseProxy(p)
|
||||
bp.type = type
|
||||
return bp
|
||||
})
|
||||
const bp = new BaseProxy(proxy)
|
||||
bp.type = type
|
||||
return bp
|
||||
}
|
||||
|
||||
const allProxyTypes = [
|
||||
'tcp',
|
||||
'udp',
|
||||
'http',
|
||||
'https',
|
||||
'tcpmux',
|
||||
'stcp',
|
||||
'xtcp',
|
||||
'sudp',
|
||||
]
|
||||
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||
return converted.filter((item): item is BaseProxy => item !== null)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
proxies.value = []
|
||||
const fetchData = async (silent = false) => {
|
||||
const seq = ++requestSeq
|
||||
if (!silent) loading.value = true
|
||||
|
||||
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 results = await Promise.all(
|
||||
allProxyTypes.map(async (t) => {
|
||||
const json = await getProxiesByType(t)
|
||||
return convertProxies(t, json)
|
||||
}),
|
||||
)
|
||||
proxies.value = results.flat()
|
||||
} else {
|
||||
const json = await getProxiesByType(type)
|
||||
proxies.value = await convertProxies(type, json)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if (seq !== requestSeq) return
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Failed to fetch proxies: ' + error.message,
|
||||
type: 'error',
|
||||
})
|
||||
} 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 () => {
|
||||
showClearDialog.value = false
|
||||
await clearOfflineProxies()
|
||||
@@ -402,20 +421,36 @@ const clearOfflineProxies = async () => {
|
||||
|
||||
// Watch for type changes
|
||||
watch(activeType, (newType) => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
// Update route but preserve query params
|
||||
router.replace({ params: { type: newType }, query: route.query })
|
||||
fetchData()
|
||||
})
|
||||
|
||||
watch(searchText, () => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
fetchData()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// Watch for route query changes (client filter)
|
||||
watch(
|
||||
() => [route.query.clientID, route.query.user],
|
||||
([newClientID, newUser]) => {
|
||||
clientIDFilter.value = (newClientID as string) || ''
|
||||
userFilter.value = (newUser as string) || ''
|
||||
resetPageAndFetch()
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSearchDebounce()
|
||||
})
|
||||
|
||||
// Initial fetch
|
||||
fetchData()
|
||||
fetchClients()
|
||||
@@ -539,6 +574,11 @@ fetchClients()
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
@@ -547,5 +587,9 @@ fetchClients()
|
||||
.client-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
70
web/package-lock.json
generated
70
web/package-lock.json
generated
@@ -954,76 +954,6 @@
|
||||
"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": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
|
||||
Reference in New Issue
Block a user