mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ff5557571
|
|||
|
bce3d5a3d8
|
|||
|
33a29b04e4
|
|||
|
481ccad05e
|
|||
|
521542b796
|
|||
|
e487e7f96f
|
|||
|
2f73a38a2f
|
|||
|
a9eb5e6901
|
|||
|
386df7f426
|
|||
|
79d5cc00ab
|
|||
|
d43a4e8950
|
|||
|
d205f2bb35
|
|||
|
a2b9062f2c
|
|||
|
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
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TAG_FRPC }}
|
||||
${{ env.TAG_FRPC_GPR }}
|
||||
|
||||
- name: Build and push frps
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frps
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TAG_FRPS }}
|
||||
${{ env.TAG_FRPS_GPR }}
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ all: build
|
||||
build: app
|
||||
|
||||
app:
|
||||
@set -e; $(foreach n, $(os-archs), \
|
||||
@$(foreach n, $(os-archs), \
|
||||
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
||||
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
||||
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
||||
|
||||
21
README.md
21
README.md
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://circleci.com/gh/fatedier/frp)
|
||||
[](https://github.com/fatedier/frp/releases)
|
||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||
|
||||
[README](README.md) | [中文文档](README_zh.md)
|
||||
@@ -12,16 +13,6 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -39,6 +30,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## What is frp?
|
||||
|
||||
20
README_zh.md
20
README_zh.md
@@ -15,16 +15,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -42,6 +32,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
10
Release.md
10
Release.md
@@ -1,9 +1,9 @@
|
||||
## Features
|
||||
|
||||
* Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
|
||||
* `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`.
|
||||
|
||||
## Fixes
|
||||
## Compatibility Notes
|
||||
|
||||
* WebSocket and WSS tunnel payloads are now sent as binary frames, avoiding disconnects through RFC-compliant intermediaries that validate text frames as UTF-8.
|
||||
* The `tls2raw` client plugin now writes the proxy protocol header to the local raw connection when proxy protocol is enabled.
|
||||
* frpc now rejects duplicate proxy and visitor names in config files instead of silently overwriting earlier entries.
|
||||
* 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,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()
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
25
go.mod
25
go.mod
@@ -4,8 +4,10 @@ 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.2-0.20260715094513-2780ae1d8cdb
|
||||
github.com/fatedier/golib v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
@@ -13,6 +15,7 @@ require (
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.36.3
|
||||
github.com/pelletier/go-toml/v2 v2.2.0
|
||||
github.com/pion/stun/v3 v3.1.1
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.55.0
|
||||
@@ -40,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
|
||||
@@ -52,18 +61,29 @@ 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
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/templexxx/cpu v0.1.1 // indirect
|
||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/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
|
||||
@@ -77,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
|
||||
|
||||
47
go.sum
47
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.2-0.20260715094513-2780ae1d8cdb h1:6gcaE769mFb1G/cNV6lgzmwIYkNXmhj4pmcddllfmB4=
|
||||
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb/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,14 +86,29 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -97,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=
|
||||
@@ -138,6 +170,10 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||
@@ -152,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=
|
||||
@@ -179,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")
|
||||
|
||||
@@ -394,10 +394,6 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -421,31 +417,6 @@ func LoadClientConfig(path string, strict bool) (
|
||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
||||
}
|
||||
|
||||
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
|
||||
// keyed by name in the config sources, so a duplicate would otherwise be silently
|
||||
// overwritten and never started, with no error or log.
|
||||
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
||||
proxyNames := make(map[string]struct{}, len(proxies))
|
||||
for _, p := range proxies {
|
||||
name := p.GetBaseConfig().Name
|
||||
if _, ok := proxyNames[name]; ok {
|
||||
return fmt.Errorf("proxy name [%s] is duplicated", name)
|
||||
}
|
||||
proxyNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
visitorNames := make(map[string]struct{}, len(visitors))
|
||||
for _, v := range visitors {
|
||||
name := v.GetBaseConfig().Name
|
||||
if _, ok := visitorNames[name]; ok {
|
||||
return fmt.Errorf("visitor name [%s] is duplicated", name)
|
||||
}
|
||||
visitorNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||
proxyCfgs := proxies
|
||||
for _, c := range proxyCfgs {
|
||||
|
||||
@@ -17,8 +17,6 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -464,111 +462,6 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
||||
}
|
||||
|
||||
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "duplicate proxy names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "dup"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[proxies]]
|
||||
name = "dup"
|
||||
type = "tcp"
|
||||
localPort = 3306
|
||||
remotePort = 6001
|
||||
`,
|
||||
errSubstr: "proxy name [dup] is duplicated",
|
||||
},
|
||||
{
|
||||
name: "duplicate visitor names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[visitors]]
|
||||
name = "dup"
|
||||
type = "stcp"
|
||||
serverName = "a"
|
||||
secretKey = "secret"
|
||||
bindPort = 9001
|
||||
|
||||
[[visitors]]
|
||||
name = "dup"
|
||||
type = "stcp"
|
||||
serverName = "b"
|
||||
secretKey = "secret"
|
||||
bindPort = 9002
|
||||
`,
|
||||
errSubstr: "visitor name [dup] is duplicated",
|
||||
},
|
||||
{
|
||||
name: "unique names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "p1"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[proxies]]
|
||||
name = "p2"
|
||||
type = "tcp"
|
||||
localPort = 3306
|
||||
remotePort = 6001
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "same name across proxy and visitor",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "same"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[visitors]]
|
||||
name = "same"
|
||||
type = "stcp"
|
||||
serverName = "a"
|
||||
secretKey = "secret"
|
||||
bindPort = 9001
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
path := filepath.Join(t.TempDir(), "frpc.toml")
|
||||
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
|
||||
|
||||
_, err := LoadClientConfigResult(path, false)
|
||||
if tc.errSubstr == "" {
|
||||
require.NoError(err)
|
||||
} else {
|
||||
require.ErrorContains(err, tc.errSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||
func TestYAMLEdgeCases(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
@@ -49,7 +49,8 @@ type ClientCommonConfig struct {
|
||||
// STUN server to help penetrate NAT hole.
|
||||
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
||||
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
||||
// is "", the default DNS will be used.
|
||||
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
|
||||
// supported, e.g. "https://1.1.1.1/dns-query".
|
||||
DNSServer string `json:"dnsServer,omitempty"`
|
||||
// LoginFailExit controls whether or not the client should exit after a
|
||||
// failed login attempt. If false, the client will retry until a login
|
||||
|
||||
@@ -369,6 +369,12 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
|
||||
type HTTPSProxyConfig struct {
|
||||
ProxyBaseConfig
|
||||
DomainConfig
|
||||
|
||||
// HTTPRedirect requests frps to redirect plain HTTP requests for the
|
||||
// proxy's domains to their HTTPS endpoint. It only takes effect when
|
||||
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
|
||||
// proxy registered on the same domain.
|
||||
HTTPRedirect bool `json:"httpRedirect,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
@@ -376,6 +382,7 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
|
||||
m.CustomDomains = c.CustomDomains
|
||||
m.SubDomain = c.SubDomain
|
||||
m.HTTPRedirect = c.HTTPRedirect
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||
@@ -383,6 +390,7 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
|
||||
|
||||
c.CustomDomains = m.CustomDomains
|
||||
c.SubDomain = m.SubDomain
|
||||
c.HTTPRedirect = m.HTTPRedirect
|
||||
}
|
||||
|
||||
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
|
||||
|
||||
@@ -16,6 +16,7 @@ package v1
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
@@ -24,29 +25,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PluginHTTP2HTTPS = "http2https"
|
||||
PluginHTTPProxy = "http_proxy"
|
||||
PluginHTTPS2HTTP = "https2http"
|
||||
PluginHTTPS2HTTPS = "https2https"
|
||||
PluginHTTP2HTTP = "http2http"
|
||||
PluginSocks5 = "socks5"
|
||||
PluginStaticFile = "static_file"
|
||||
PluginUnixDomainSocket = "unix_domain_socket"
|
||||
PluginTLS2Raw = "tls2raw"
|
||||
PluginVirtualNet = "virtual_net"
|
||||
PluginHTTP2HTTPS = "http2https"
|
||||
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
||||
PluginHTTPProxy = "http_proxy"
|
||||
PluginHTTPS2HTTP = "https2http"
|
||||
PluginHTTPS2HTTPS = "https2https"
|
||||
PluginHTTP2HTTP = "http2http"
|
||||
PluginSocks5 = "socks5"
|
||||
PluginStaticFile = "static_file"
|
||||
PluginUnixDomainSocket = "unix_domain_socket"
|
||||
PluginTLS2Raw = "tls2raw"
|
||||
PluginVirtualNet = "virtual_net"
|
||||
)
|
||||
|
||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
|
||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||
}
|
||||
|
||||
type ClientPluginOptions interface {
|
||||
@@ -80,6 +83,29 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
|
||||
return jsonx.Marshal(c.ClientPluginOptions)
|
||||
}
|
||||
|
||||
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
|
||||
// that terminate TLS locally.
|
||||
type AutoTLSOptions struct {
|
||||
Enable bool `json:"enable,omitempty"`
|
||||
// Contact email for certificate expiration and important notices.
|
||||
Email string `json:"email,omitempty"`
|
||||
// Directory used to cache ACME account and certificates.
|
||||
CacheDir string `json:"cacheDir,omitempty"`
|
||||
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
|
||||
CADirURL string `json:"caDirURL,omitempty"`
|
||||
// Restrict certificate issuance to the listed domains.
|
||||
HostAllowList []string `json:"hostAllowList,omitempty"`
|
||||
}
|
||||
|
||||
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
out.HostAllowList = slices.Clone(o.HostAllowList)
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTP2HTTPSPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
@@ -98,6 +124,21 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTP2HTTPSRedirectPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
HTTPSPort int `json:"httpsPort,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
|
||||
|
||||
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
return &out
|
||||
}
|
||||
|
||||
type HTTPProxyPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
HTTPUser string `json:"httpUser,omitempty"`
|
||||
@@ -122,6 +163,7 @@ type HTTPS2HTTPPluginOptions struct {
|
||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||
@@ -135,6 +177,7 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
||||
out := *o
|
||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -146,6 +189,7 @@ type HTTPS2HTTPSPluginOptions struct {
|
||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||
@@ -159,6 +203,7 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
out := *o
|
||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -230,10 +275,11 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
|
||||
}
|
||||
|
||||
type TLS2RawPluginOptions struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
LocalAddr string `json:"localAddr,omitempty"`
|
||||
CrtPath string `json:"crtPath,omitempty"`
|
||||
KeyPath string `json:"keyPath,omitempty"`
|
||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||
}
|
||||
|
||||
func (o *TLS2RawPluginOptions) Complete() {}
|
||||
@@ -243,6 +289,7 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
||||
return nil
|
||||
}
|
||||
out := *o
|
||||
out.AutoTLS = o.AutoTLS.Clone()
|
||||
return &out
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -95,7 +95,9 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
defer m.mu.Unlock()
|
||||
total = len(m.info.ProxyStatistics)
|
||||
for name, data := range m.info.ProxyStatistics {
|
||||
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||
if !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
delete(m.info.ProxyStatistics, name)
|
||||
count++
|
||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||
@@ -104,20 +106,10 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
return count, total
|
||||
}
|
||||
|
||||
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
|
||||
return !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
|
||||
}
|
||||
|
||||
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewClient() {
|
||||
m.info.ClientCounts.Inc(1)
|
||||
}
|
||||
@@ -239,11 +231,9 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
||||
}
|
||||
if !proxyStats.LastStartTime.IsZero() {
|
||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||
}
|
||||
if !proxyStats.LastCloseTime.IsZero() {
|
||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||
}
|
||||
return ps
|
||||
}
|
||||
@@ -253,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))
|
||||
@@ -267,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
|
||||
}
|
||||
|
||||
@@ -22,12 +22,6 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||
clk.SetTime(closedAt)
|
||||
metrics.CloseProxy("proxy", "tcp")
|
||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||
|
||||
stats := metrics.GetProxyByName("proxy")
|
||||
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
|
||||
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
|
||||
require.Equal(start.Unix(), stats.LastStartAt)
|
||||
require.Equal(closedAt.Unix(), stats.LastCloseAt)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
@@ -49,70 +43,6 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
require.Empty(metrics.info.ProxyStatistics)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.ClearOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(2, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
}
|
||||
|
||||
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
|
||||
Name: "restarted",
|
||||
LastStartTime: start.Add(30 * time.Second),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
|
||||
Name: "same-time",
|
||||
LastStartTime: start,
|
||||
LastCloseTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.PruneOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(4, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
require.True(metrics.hasProxyStatistics("restarted"))
|
||||
require.True(metrics.hasProxyStatistics("same-time"))
|
||||
|
||||
cleared, total = metrics.PruneOfflineProxies()
|
||||
require.Equal(0, cleared)
|
||||
require.Equal(3, total)
|
||||
}
|
||||
|
||||
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ type ProxyStats struct {
|
||||
TodayTrafficOut int64
|
||||
LastStartTime string
|
||||
LastCloseTime string
|
||||
LastStartAt int64
|
||||
LastCloseAt int64
|
||||
CurConns int64
|
||||
}
|
||||
|
||||
@@ -87,5 +85,4 @@ type Collector interface {
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
PruneOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ type NewProxy struct {
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
|
||||
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
|
||||
HTTPRedirect bool `json:"http_redirect,omitempty"`
|
||||
|
||||
// stcp, sudp, xtcp
|
||||
Sk string `json:"sk,omitempty"`
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -15,24 +15,31 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
"github.com/pion/stun/v3"
|
||||
)
|
||||
|
||||
var responseTimeout = 3 * time.Second
|
||||
|
||||
type Message struct {
|
||||
Body []byte
|
||||
Addr string
|
||||
}
|
||||
|
||||
// If the localAddr is empty, it will listen on a random port.
|
||||
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
||||
// create a discoverConn and get response from messageChan
|
||||
discoverConn, err := listen(localAddr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer discoverConn.Close()
|
||||
|
||||
go discoverConn.readLoop()
|
||||
|
||||
addresses := make([]string, 0, len(stunServers))
|
||||
for _, addr := range stunServers {
|
||||
// get external address from stun server
|
||||
@@ -51,9 +58,10 @@ type stunResponse struct {
|
||||
}
|
||||
|
||||
type discoverConn struct {
|
||||
conn *net.UDPConn
|
||||
client *stun.Client
|
||||
localAddr net.Addr
|
||||
conn *net.UDPConn
|
||||
|
||||
localAddr net.Addr
|
||||
messageChan chan *Message
|
||||
}
|
||||
|
||||
func listen(localAddr string) (*discoverConn, error) {
|
||||
@@ -69,50 +77,82 @@ func listen(localAddr string) (*discoverConn, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := stun.NewClient(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &discoverConn{
|
||||
conn: conn,
|
||||
client: client,
|
||||
localAddr: conn.LocalAddr(),
|
||||
conn: conn,
|
||||
localAddr: conn.LocalAddr(),
|
||||
messageChan: make(chan *Message, 10),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *discoverConn) Close() error {
|
||||
if c.messageChan != nil {
|
||||
close(c.messageChan)
|
||||
c.messageChan = nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *discoverConn) readLoop() {
|
||||
for {
|
||||
buf := make([]byte, 1024)
|
||||
n, addr, err := c.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
c.messageChan <- &Message{
|
||||
Body: buf,
|
||||
Addr: addr.String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
|
||||
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transaction, err := stun.NewBindingTransaction(serverAddr)
|
||||
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := c.client.Do(transaction)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &stunResponse{}
|
||||
if response.MappedAddr != nil {
|
||||
resp.externalAddr = response.MappedAddr.String()
|
||||
if err = request.NewTransactionID(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.OtherAddr != nil {
|
||||
resp.otherAddr = response.OtherAddr.String()
|
||||
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var m stun.Message
|
||||
select {
|
||||
case msg := <-c.messageChan:
|
||||
m.Raw = msg.Body
|
||||
if err := m.Decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case <-time.After(responseTimeout):
|
||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||
}
|
||||
xorAddrGetter := &stun.XORMappedAddress{}
|
||||
mappedAddrGetter := &stun.MappedAddress{}
|
||||
changedAddrGetter := ChangedAddress{}
|
||||
otherAddrGetter := &stun.OtherAddress{}
|
||||
|
||||
resp := &stunResponse{}
|
||||
if err := mappedAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.externalAddr = mappedAddrGetter.String()
|
||||
}
|
||||
if err := xorAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.externalAddr = xorAddrGetter.String()
|
||||
}
|
||||
if err := changedAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.otherAddr = changedAddrGetter.String()
|
||||
}
|
||||
if err := otherAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.otherAddr = otherAddrGetter.String()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testBindingRequest = 0x0001
|
||||
testBindingSuccess = 0x0101
|
||||
testBindingError = 0x0111
|
||||
testMagicCookie = 0x2112a442
|
||||
testAttrMapped = 0x0001
|
||||
testAttrChanged = 0x0005
|
||||
testAttrErrorCode = 0x0009
|
||||
testAttrXORMapped = 0x0020
|
||||
testAttrOther = 0x802c
|
||||
testSTUNHeaderSize = 20
|
||||
testSTUNServerLimit = time.Second
|
||||
)
|
||||
|
||||
type testSTUNAttribute struct {
|
||||
typ uint16
|
||||
value []byte
|
||||
}
|
||||
|
||||
type testSTUNExchange struct {
|
||||
source *net.UDPAddr
|
||||
err error
|
||||
}
|
||||
|
||||
func listenTestUDP4(t *testing.T) *net.UDPConn {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func serveOneSTUNRequest(
|
||||
server *net.UDPConn,
|
||||
buildResponse func([]byte, *net.UDPAddr) ([]byte, error),
|
||||
) <-chan testSTUNExchange {
|
||||
done := make(chan testSTUNExchange, 1)
|
||||
go func() {
|
||||
if err := server.SetDeadline(time.Now().Add(testSTUNServerLimit)); err != nil {
|
||||
done <- testSTUNExchange{err: err}
|
||||
return
|
||||
}
|
||||
buffer := make([]byte, 1024)
|
||||
n, source, err := server.ReadFromUDP(buffer)
|
||||
if err == nil && buildResponse != nil {
|
||||
var response []byte
|
||||
response, err = buildResponse(buffer[:n], source)
|
||||
if err == nil && response != nil {
|
||||
_, err = server.WriteToUDP(response, source)
|
||||
}
|
||||
}
|
||||
done <- testSTUNExchange{source: source, err: err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func waitSTUNExchange(t *testing.T, done <-chan testSTUNExchange) *net.UDPAddr {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case exchange := <-done:
|
||||
require.NoError(t, exchange.err)
|
||||
return exchange.source
|
||||
case <-time.After(testSTUNServerLimit):
|
||||
t.Fatal("timed out waiting for local STUN server")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestSTUNResponse(request []byte, typ uint16, attributes ...testSTUNAttribute) ([]byte, error) {
|
||||
if len(request) != testSTUNHeaderSize || binary.BigEndian.Uint16(request[0:2]) != testBindingRequest ||
|
||||
binary.BigEndian.Uint32(request[4:8]) != testMagicCookie {
|
||||
return nil, fmt.Errorf("invalid Binding request")
|
||||
}
|
||||
|
||||
length := 0
|
||||
for _, attribute := range attributes {
|
||||
length += 4 + (len(attribute.value)+3)&^3
|
||||
}
|
||||
response := make([]byte, testSTUNHeaderSize, testSTUNHeaderSize+length)
|
||||
binary.BigEndian.PutUint16(response[0:2], typ)
|
||||
binary.BigEndian.PutUint16(response[2:4], uint16(length))
|
||||
binary.BigEndian.PutUint32(response[4:8], testMagicCookie)
|
||||
copy(response[8:20], request[8:20])
|
||||
|
||||
for _, attribute := range attributes {
|
||||
start := len(response)
|
||||
paddedLength := (len(attribute.value) + 3) &^ 3
|
||||
response = append(response, make([]byte, 4+paddedLength)...)
|
||||
binary.BigEndian.PutUint16(response[start:start+2], attribute.typ)
|
||||
binary.BigEndian.PutUint16(response[start+2:start+4], uint16(len(attribute.value)))
|
||||
copy(response[start+4:], attribute.value)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func testIPv4AddressValue(ip net.IP, port int, xor bool) []byte {
|
||||
value := make([]byte, 8)
|
||||
value[1] = 0x01
|
||||
binary.BigEndian.PutUint16(value[2:4], uint16(port))
|
||||
copy(value[4:], ip.To4())
|
||||
if xor {
|
||||
binary.BigEndian.PutUint16(value[2:4], binary.BigEndian.Uint16(value[2:4])^uint16(testMagicCookie>>16))
|
||||
for i := range 4 {
|
||||
value[4+i] ^= byte(uint32(testMagicCookie) >> uint(24-8*i))
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestDiscoverReusesLocalPortAndPreservesNATClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secondMapped string
|
||||
secondMappedPort int
|
||||
wantNATType string
|
||||
wantBehavior string
|
||||
}{
|
||||
{
|
||||
name: "same mapped address",
|
||||
secondMapped: "198.51.100.10:40000",
|
||||
secondMappedPort: 40000,
|
||||
wantNATType: EasyNAT,
|
||||
wantBehavior: BehaviorNoChange,
|
||||
},
|
||||
{
|
||||
name: "different mapped port",
|
||||
secondMapped: "198.51.100.10:40001",
|
||||
secondMappedPort: 40001,
|
||||
wantNATType: HardNAT,
|
||||
wantBehavior: BehaviorPortChanged,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
primary := listenTestUDP4(t)
|
||||
alternate := listenTestUDP4(t)
|
||||
alternateAddr := alternate.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
primaryDone := serveOneSTUNRequest(primary, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), 40000, true)},
|
||||
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(alternateAddr.IP, alternateAddr.Port, false)},
|
||||
)
|
||||
})
|
||||
alternateDone := serveOneSTUNRequest(alternate, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), tt.secondMappedPort, true)},
|
||||
)
|
||||
})
|
||||
|
||||
addresses, localAddr, err := Discover([]string{primary.LocalAddr().String()}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"198.51.100.10:40000", tt.secondMapped}, addresses)
|
||||
|
||||
primarySource := waitSTUNExchange(t, primaryDone)
|
||||
alternateSource := waitSTUNExchange(t, alternateDone)
|
||||
require.Equal(t, primarySource.Port, alternateSource.Port)
|
||||
require.Equal(t, localAddr.(*net.UDPAddr).Port, primarySource.Port)
|
||||
|
||||
feature, err := ClassifyNATFeature(addresses, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantNATType, feature.NatType)
|
||||
require.Equal(t, tt.wantBehavior, feature.Behavior)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSTUNRequestMapsLegacyAndModernAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attributes []testSTUNAttribute
|
||||
wantExternal string
|
||||
wantOther string
|
||||
}{
|
||||
{
|
||||
name: "legacy",
|
||||
attributes: []testSTUNAttribute{
|
||||
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
},
|
||||
wantExternal: "192.0.2.1:1000",
|
||||
wantOther: "192.0.2.2:2000",
|
||||
},
|
||||
{
|
||||
name: "modern takes precedence",
|
||||
attributes: []testSTUNAttribute{
|
||||
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||
{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("198.51.100.2"), 4000, false)},
|
||||
},
|
||||
wantExternal: "198.51.100.1:3000",
|
||||
wantOther: "198.51.100.2:4000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess, tt.attributes...)
|
||||
})
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantExternal, response.externalAddr)
|
||||
require.Equal(t, tt.wantOther, response.otherAddr)
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNResponseErrorsAndMissingAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildResponse func([]byte, *net.UDPAddr) ([]byte, error)
|
||||
request func(*discoverConn, string) error
|
||||
checkError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "correlated malformed response",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
response, err := makeTestSTUNResponse(request, testBindingSuccess)
|
||||
if err == nil {
|
||||
binary.BigEndian.PutUint16(response[2:4], 4)
|
||||
}
|
||||
return response, err
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.doSTUNRequest(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
require.ErrorIs(t, err, stun.ErrMalformedResponse)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Binding error response",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingError, testSTUNAttribute{
|
||||
typ: testAttrErrorCode,
|
||||
value: []byte{0, 0, 4, 20, 'U', 'n', 'k', 'n', 'o', 'w', 'n'},
|
||||
})
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.doSTUNRequest(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
var responseErr *stun.ResponseError
|
||||
require.ErrorAs(t, err, &responseErr)
|
||||
require.Equal(t, 420, responseErr.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mapped address",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
)
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.discoverFromStunServer(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
require.EqualError(t, err, "no external address found")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, tt.buildResponse)
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
err = tt.request(conn, server.LocalAddr().String())
|
||||
tt.checkError(t, err)
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("missing other address", func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||
)
|
||||
})
|
||||
|
||||
_, err := Prepare([]string{server.LocalAddr().String()}, PrepareOptions{})
|
||||
require.EqualError(t, err, "discover error: not enough addresses")
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSTUNTimeoutUsesCallerDeadlineWithoutRetry(t *testing.T) {
|
||||
originalTimeout := responseTimeout
|
||||
responseTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, nil)
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_, err = conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.EqualError(t, err, "wait response from stun server timeout")
|
||||
waitSTUNExchange(t, done)
|
||||
|
||||
require.NoError(t, server.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
|
||||
_, _, err = server.ReadFromUDP(make([]byte, 1))
|
||||
var netErr net.Error
|
||||
require.ErrorAs(t, err, &netErr)
|
||||
require.True(t, netErr.Timeout())
|
||||
}
|
||||
|
||||
func TestSTUNClientLeavesSocketAndDeadlineWithCaller(t *testing.T) {
|
||||
originalTimeout := responseTimeout
|
||||
responseTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||
|
||||
server := listenTestUDP4(t)
|
||||
unrelated := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, source *net.UDPAddr) ([]byte, error) {
|
||||
response, err := makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.5"), 5000, true)},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := unrelated.WriteToUDP(response, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
})
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "198.51.100.5:5000", response.externalAddr)
|
||||
waitSTUNExchange(t, done)
|
||||
|
||||
_, _, err = conn.conn.ReadFromUDP(make([]byte, 1))
|
||||
var netErr net.Error
|
||||
require.True(t, errors.As(err, &netErr))
|
||||
require.True(t, netErr.Timeout())
|
||||
|
||||
require.NoError(t, conn.conn.SetDeadline(time.Time{}))
|
||||
require.NoError(t, server.SetReadDeadline(time.Now().Add(testSTUNServerLimit)))
|
||||
_, err = conn.conn.WriteToUDP([]byte{1}, server.LocalAddr().(*net.UDPAddr))
|
||||
require.NoError(t, err)
|
||||
_, source, err := server.ReadFromUDP(make([]byte, 1))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, conn.localAddr.(*net.UDPAddr).Port, source.Port)
|
||||
}
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
"github.com/pion/stun/v3"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
@@ -46,6 +48,20 @@ func DecodeMessageInto(data, key []byte, m msg.Message) error {
|
||||
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
||||
}
|
||||
|
||||
type ChangedAddress struct {
|
||||
IP net.IP
|
||||
Port int
|
||||
}
|
||||
|
||||
func (s *ChangedAddress) GetFrom(m *stun.Message) error {
|
||||
a := (*stun.MappedAddress)(s)
|
||||
return a.GetFromAs(m, stun.AttrChangedAddress)
|
||||
}
|
||||
|
||||
func (s *ChangedAddress) String() string {
|
||||
return net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port))
|
||||
}
|
||||
|
||||
func ListAllLocalIPs() ([]net.IP, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
|
||||
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
|
||||
@@ -72,15 +81,6 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
if connInfo.ProxyProtocolHeader != nil {
|
||||
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||
rawConn.Close()
|
||||
tlsConn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
libio.Join(tlsConn, rawConn)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goliblog "github.com/fatedier/golib/log"
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||
@@ -39,8 +39,8 @@ type testPlugin struct {
|
||||
var logCaptureMu sync.Mutex
|
||||
|
||||
type logCapture struct {
|
||||
bytes.Buffer
|
||||
levels []goliblog.Level
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (p testPlugin) Name() string {
|
||||
@@ -55,9 +55,36 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
|
||||
return p.handler(ctx, op, content)
|
||||
}
|
||||
|
||||
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
||||
w.levels = append(w.levels, level)
|
||||
return w.Write(p)
|
||||
func (w *logCapture) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func (w *logCapture) String() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.String()
|
||||
}
|
||||
|
||||
// levels parses the captured JSON log output and returns the level of each entry.
|
||||
func (w *logCapture) levels() []charmlog.Level {
|
||||
var levels []charmlog.Level
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(w.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var entry struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
|
||||
levels = append(levels, lvl)
|
||||
}
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
func captureLogOutput(t *testing.T) *logCapture {
|
||||
@@ -66,11 +93,10 @@ func captureLogOutput(t *testing.T) *logCapture {
|
||||
logCaptureMu.Lock()
|
||||
logOutput := &logCapture{}
|
||||
oldLogger := frplog.Logger
|
||||
frplog.Logger = goliblog.New(
|
||||
goliblog.WithOutput(logOutput),
|
||||
goliblog.WithLevel(goliblog.TraceLevel),
|
||||
goliblog.WithCaller(false),
|
||||
)
|
||||
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
|
||||
Level: charmlog.DebugLevel,
|
||||
Formatter: charmlog.JSONFormatter,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
frplog.Logger = oldLogger
|
||||
logCaptureMu.Unlock()
|
||||
@@ -263,10 +289,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op string
|
||||
level goliblog.Level
|
||||
level charmlog.Level
|
||||
}{
|
||||
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
||||
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
||||
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
|
||||
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -288,8 +314,9 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
||||
levels := logOutput.levels()
|
||||
if len(levels) != 1 || levels[0] != tt.level {
|
||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -325,12 +352,13 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
||||
t.Fatalf("missing aggregated errors: %v", err)
|
||||
}
|
||||
if len(logOutput.levels) != 2 {
|
||||
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
||||
levels := logOutput.levels()
|
||||
if len(levels) != 2 {
|
||||
t.Fatalf("expected two warning logs, got %v", levels)
|
||||
}
|
||||
for _, level := range logOutput.levels {
|
||||
if level != goliblog.WarnLevel {
|
||||
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
||||
for _, level := range levels {
|
||||
if level != charmlog.WarnLevel {
|
||||
t.Fatalf("expected warning log level, got %v", levels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -45,11 +45,6 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
conn.PayloadType = websocket.BinaryFrame
|
||||
return ctx, conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@ package net
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SetDefaultDNSAddress(dnsAddress string) {
|
||||
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
|
||||
if strings.HasPrefix(dnsAddress, "https://") {
|
||||
SetDefaultDNSOverHTTPS(dnsAddress)
|
||||
return
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
||||
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -32,11 +32,6 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
|
||||
muxer := http.NewServeMux()
|
||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
c.PayloadType = websocket.BinaryFrame
|
||||
notifyCh := make(chan struct{})
|
||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||
close(notifyCh)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.70.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,12 +36,13 @@ 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")
|
||||
@@ -49,15 +50,10 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
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/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||
v2EncodedPathRouter.UseEncodedPath()
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).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")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
|
||||
// view
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,32 +38,35 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
// /api/serverinfo
|
||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildServerInfoResp(), nil
|
||||
}
|
||||
|
||||
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
serverStats := mem.StatsCollector.GetServer()
|
||||
return model.ServerInfoResp{
|
||||
svrResp := model.ServerInfoResp{
|
||||
Version: version.Full(),
|
||||
BindPort: c.serverCfg.BindPort,
|
||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||
@@ -84,6 +87,8 @@ func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
ClientCounts: serverStats.ClientCounts,
|
||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||
}
|
||||
|
||||
return svrResp, nil
|
||||
}
|
||||
|
||||
// /api/clients
|
||||
@@ -220,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")
|
||||
|
||||
@@ -17,31 +17,22 @@ package http
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultV2Page = 1
|
||||
defaultV2PageSize = 50
|
||||
maxV2PageSize = 200
|
||||
|
||||
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||
v2ProxyTrafficDefaultDays = 7
|
||||
v2ProxyTrafficUnit = "bytes"
|
||||
v2ProxyTrafficGranularity = "day"
|
||||
)
|
||||
|
||||
var apiV2ProxyTypes = []string{
|
||||
@@ -55,55 +46,6 @@ var apiV2ProxyTypes = []string{
|
||||
string(v1.ProxyTypeSUDP),
|
||||
}
|
||||
|
||||
// /api/v2/system/info
|
||||
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
||||
info := c.buildServerInfoResp()
|
||||
proxyTypeCounts := info.ProxyTypeCounts
|
||||
if proxyTypeCounts == nil {
|
||||
proxyTypeCounts = map[string]int64{}
|
||||
}
|
||||
|
||||
return model.V2SystemInfoResp{
|
||||
Version: info.Version,
|
||||
Config: model.V2SystemInfoConfigResp{
|
||||
BindPort: info.BindPort,
|
||||
VhostHTTPPort: info.VhostHTTPPort,
|
||||
VhostHTTPSPort: info.VhostHTTPSPort,
|
||||
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
||||
KCPBindPort: info.KCPBindPort,
|
||||
QUICBindPort: info.QUICBindPort,
|
||||
SubdomainHost: info.SubdomainHost,
|
||||
MaxPoolCount: info.MaxPoolCount,
|
||||
MaxPortsPerClient: info.MaxPortsPerClient,
|
||||
HeartbeatTimeout: info.HeartBeatTimeout,
|
||||
AllowPortsStr: info.AllowPortsStr,
|
||||
TLSForce: info.TLSForce,
|
||||
},
|
||||
Status: model.V2SystemInfoStatusResp{
|
||||
TotalTrafficIn: info.TotalTrafficIn,
|
||||
TotalTrafficOut: info.TotalTrafficOut,
|
||||
CurConns: info.CurConns,
|
||||
ClientCounts: info.ClientCounts,
|
||||
ProxyTypeCounts: proxyTypeCounts,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/system/prune
|
||||
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
||||
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
||||
return model.V2SystemPruneResp{
|
||||
Type: pruneType,
|
||||
Cleared: cleared,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/users
|
||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||
page, pageSize, err := parseV2PageParams(ctx)
|
||||
@@ -195,26 +137,7 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/clients/{key}
|
||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||
key, err := decodeV2PathParam(ctx, "key", "client key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.clientRegistry == nil {
|
||||
return nil, fmt.Errorf("client registry unavailable")
|
||||
}
|
||||
|
||||
info, ok := c.clientRegistry.GetByKey(key)
|
||||
if !ok {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||
}
|
||||
|
||||
resp := buildClientInfoResp(info)
|
||||
status := c.buildV2ClientStatus(info)
|
||||
return model.V2ClientDetailResp{
|
||||
ClientInfoResp: resp,
|
||||
Status: status,
|
||||
}, nil
|
||||
return c.APIClientDetail(ctx)
|
||||
}
|
||||
|
||||
// /api/v2/proxies
|
||||
@@ -256,7 +179,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
}
|
||||
|
||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
|
||||
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
||||
return v
|
||||
}
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
@@ -267,9 +190,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/proxies/{name}
|
||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
name := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing proxy name")
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
@@ -279,33 +202,6 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildV2ProxyResp(ps), nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies/{name}/traffic
|
||||
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||
if proxyTrafficInfo == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
|
||||
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
||||
}
|
||||
|
||||
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
||||
raw := ctx.Param(key)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("missing %s", label)
|
||||
}
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||
item, ok := items[user]
|
||||
if !ok {
|
||||
@@ -365,18 +261,6 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||
}
|
||||
|
||||
func parseV2SystemPruneType(raw string) (string, error) {
|
||||
pruneType := strings.ToLower(raw)
|
||||
switch pruneType {
|
||||
case "":
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
||||
case v2SystemPruneTypeOfflineProxies:
|
||||
return pruneType, nil
|
||||
default:
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
||||
}
|
||||
}
|
||||
|
||||
func matchV2StatusFilter(online bool, filter string) bool {
|
||||
switch filter {
|
||||
case "", "all":
|
||||
@@ -436,36 +320,26 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||
values := []string{
|
||||
item.Name,
|
||||
item.Spec.Type,
|
||||
item.Type,
|
||||
item.User,
|
||||
item.ClientID,
|
||||
item.Status.State,
|
||||
}
|
||||
|
||||
switch item.Spec.Type {
|
||||
case string(v1.ProxyTypeTCP):
|
||||
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
||||
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
||||
}
|
||||
case string(v1.ProxyTypeUDP):
|
||||
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
||||
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
||||
}
|
||||
case string(v1.ProxyTypeHTTP):
|
||||
if item.Spec.HTTP != nil {
|
||||
values = append(values, item.Spec.HTTP.CustomDomains...)
|
||||
values = append(values, item.Spec.HTTP.Subdomain)
|
||||
}
|
||||
case string(v1.ProxyTypeHTTPS):
|
||||
if item.Spec.HTTPS != nil {
|
||||
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
||||
values = append(values, item.Spec.HTTPS.Subdomain)
|
||||
}
|
||||
case string(v1.ProxyTypeTCPMUX):
|
||||
if item.Spec.TCPMux != nil {
|
||||
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
||||
values = append(values, item.Spec.TCPMux.Subdomain)
|
||||
}
|
||||
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...)
|
||||
@@ -492,156 +366,29 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||
return items
|
||||
}
|
||||
|
||||
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||
history = append(history, model.V2ProxyTrafficPointResp{
|
||||
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||
})
|
||||
}
|
||||
|
||||
return model.V2ProxyTrafficResp{
|
||||
Name: name,
|
||||
Unit: v2ProxyTrafficUnit,
|
||||
Granularity: v2ProxyTrafficGranularity,
|
||||
History: history,
|
||||
}
|
||||
}
|
||||
|
||||
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||
if todayFirstIndex >= len(values) {
|
||||
return 0
|
||||
}
|
||||
return values[todayFirstIndex]
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||
status := model.V2ClientStatusResp{State: "offline"}
|
||||
if info.Online {
|
||||
status.State = "online"
|
||||
}
|
||||
|
||||
user := info.User
|
||||
clientID := info.ClientID()
|
||||
for _, ps := range c.listV2ProxyStats("") {
|
||||
if ps.User != user || ps.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
status.CurConns += ps.CurConns
|
||||
status.ProxyCount++
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||
state := "offline"
|
||||
var cfg v1.ProxyConfigurer
|
||||
var spec any
|
||||
if c.pxyManager != nil {
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
state = "online"
|
||||
cfg = pxy.GetConfigurer()
|
||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
}
|
||||
}
|
||||
|
||||
return model.V2ProxyResp{
|
||||
Name: ps.Name,
|
||||
Type: ps.Type,
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
Spec: buildV2ProxySpec(ps.Type, cfg),
|
||||
Spec: spec,
|
||||
Status: model.V2ProxyStatusResp{
|
||||
State: state,
|
||||
TodayTrafficIn: ps.TodayTrafficIn,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartAt: ps.LastStartAt,
|
||||
LastCloseAt: ps.LastCloseAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
||||
spec := model.V2ProxySpec{Type: proxyType}
|
||||
|
||||
switch proxyType {
|
||||
case string(v1.ProxyTypeTCP):
|
||||
block := &model.V2TCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.RemotePort = &c.RemotePort
|
||||
}
|
||||
spec.TCP = block
|
||||
case string(v1.ProxyTypeUDP):
|
||||
block := &model.V2UDPProxySpec{}
|
||||
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.RemotePort = &c.RemotePort
|
||||
}
|
||||
spec.UDP = block
|
||||
case string(v1.ProxyTypeHTTP):
|
||||
block := &model.V2HTTPProxySpec{}
|
||||
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
block.Locations = slices.Clone(c.Locations)
|
||||
block.HostHeaderRewrite = c.HostHeaderRewrite
|
||||
}
|
||||
spec.HTTP = block
|
||||
case string(v1.ProxyTypeHTTPS):
|
||||
block := &model.V2HTTPSProxySpec{}
|
||||
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
}
|
||||
spec.HTTPS = block
|
||||
case string(v1.ProxyTypeTCPMUX):
|
||||
block := &model.V2TCPMuxProxySpec{}
|
||||
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
block.Multiplexer = c.Multiplexer
|
||||
block.RouteByHTTPUser = c.RouteByHTTPUser
|
||||
}
|
||||
spec.TCPMux = block
|
||||
case string(v1.ProxyTypeSTCP):
|
||||
block := &model.V2STCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.STCP = block
|
||||
case string(v1.ProxyTypeSUDP):
|
||||
block := &model.V2SUDPProxySpec{}
|
||||
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.SUDP = block
|
||||
case string(v1.ProxyTypeXTCP):
|
||||
block := &model.V2XTCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.XTCP = block
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
||||
return model.V2ProxyBaseSpec{
|
||||
Annotations: maps.Clone(base.Annotations),
|
||||
Metadatas: maps.Clone(base.Metadatas),
|
||||
Transport: &model.V2ProxyTransportSpec{
|
||||
UseEncryption: base.Transport.UseEncryption,
|
||||
UseCompression: base.Transport.UseCompression,
|
||||
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
||||
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
||||
},
|
||||
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
||||
Group: base.LoadBalancer.Group,
|
||||
LastStartTime: ps.LastStartTime,
|
||||
LastCloseTime: ps.LastCloseTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configtypes "github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
)
|
||||
|
||||
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
proxyType string
|
||||
cfg v1.ProxyConfigurer
|
||||
blockKeys []string
|
||||
}{
|
||||
{
|
||||
proxyType: "tcp",
|
||||
cfg: &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
|
||||
RemotePort: 6000,
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "udp",
|
||||
cfg: &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||
RemotePort: 7000,
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "http",
|
||||
cfg: &v1.HTTPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
|
||||
Locations: []string{"/api"},
|
||||
HTTPUser: "secret-http-user",
|
||||
HTTPPassword: "secret-http-password",
|
||||
HostHeaderRewrite: "backend.example.com",
|
||||
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
|
||||
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
|
||||
RouteByHTTPUser: "secret-http-route-user",
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "https",
|
||||
cfg: &v1.HTTPSProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "tcpmux",
|
||||
cfg: &v1.TCPMuxProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
|
||||
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
|
||||
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
|
||||
RouteByHTTPUser: "displayed-mux-user",
|
||||
Multiplexer: "httpconnect",
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "stcp",
|
||||
cfg: &v1.STCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
|
||||
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "sudp",
|
||||
cfg: &v1.SUDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
|
||||
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "xtcp",
|
||||
cfg: &v1.XTCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
|
||||
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.proxyType, func(t *testing.T) {
|
||||
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
|
||||
raw := mustMarshalJSON(t, spec)
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &specObject); err != nil {
|
||||
t.Fatalf("unmarshal spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
|
||||
|
||||
var gotType string
|
||||
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
|
||||
t.Fatalf("unmarshal spec type failed: %v", err)
|
||||
}
|
||||
if gotType != tt.proxyType {
|
||||
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
|
||||
}
|
||||
|
||||
var block map[string]json.RawMessage
|
||||
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
|
||||
t.Fatalf("unmarshal active block failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, block, tt.blockKeys...)
|
||||
assertV2ProxyCommonSpec(t, block)
|
||||
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
|
||||
assertNoV2ProxySensitiveFields(t, block)
|
||||
|
||||
content := string(raw)
|
||||
for _, secret := range []string{
|
||||
"secret-proxy-name",
|
||||
"secret-group-key",
|
||||
"secret-local-host",
|
||||
"secret-plugin-user",
|
||||
"secret-plugin-password",
|
||||
"secret-health-path",
|
||||
"secret-http-user",
|
||||
"secret-http-password",
|
||||
"secret-request-header",
|
||||
"secret-response-header",
|
||||
"secret-http-route-user",
|
||||
"secret-mux-http-user",
|
||||
"secret-mux-http-password",
|
||||
"secret-stcp-key",
|
||||
"secret-stcp-user",
|
||||
"secret-sudp-key",
|
||||
"secret-sudp-user",
|
||||
"secret-xtcp-key",
|
||||
"secret-xtcp-user",
|
||||
} {
|
||||
if strings.Contains(content, secret) {
|
||||
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
switch proxyType {
|
||||
case "tcp":
|
||||
var block model.V2TCPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal tcp block failed: %v", err)
|
||||
}
|
||||
if block.RemotePort == nil || *block.RemotePort != 6000 {
|
||||
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
|
||||
}
|
||||
case "udp":
|
||||
var block model.V2UDPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal udp block failed: %v", err)
|
||||
}
|
||||
if block.RemotePort == nil || *block.RemotePort != 7000 {
|
||||
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
|
||||
}
|
||||
case "http":
|
||||
var block model.V2HTTPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal http block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
|
||||
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
|
||||
block.HostHeaderRewrite != "backend.example.com" {
|
||||
t.Fatalf("http fields mismatch: %#v", block)
|
||||
}
|
||||
case "https":
|
||||
var block model.V2HTTPSProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal https block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
|
||||
t.Fatalf("https fields mismatch: %#v", block)
|
||||
}
|
||||
case "tcpmux":
|
||||
var block model.V2TCPMuxProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal tcpmux block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
|
||||
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
|
||||
t.Fatalf("tcpmux fields mismatch: %#v", block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
|
||||
for _, proxyType := range apiV2ProxyTypes {
|
||||
t.Run(proxyType, func(t *testing.T) {
|
||||
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
|
||||
Name: "offline-" + proxyType,
|
||||
Type: proxyType,
|
||||
})
|
||||
if resp.Status.State != "offline" {
|
||||
t.Fatalf("offline phase mismatch: %#v", resp.Status)
|
||||
}
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
|
||||
t.Fatalf("unmarshal offline spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, proxyType, "type")
|
||||
assertRawJSONKeysFromMessage(t, specObject[proxyType])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
|
||||
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||
RemotePort: 7000,
|
||||
})
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
|
||||
t.Fatalf("unmarshal mismatched spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, specObject["tcp"])
|
||||
}
|
||||
|
||||
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
|
||||
t.Helper()
|
||||
|
||||
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
|
||||
if err != nil {
|
||||
t.Fatalf("create bandwidth limit failed: %v", err)
|
||||
}
|
||||
enabled := false
|
||||
return v1.ProxyBaseConfig{
|
||||
Name: "secret-proxy-name",
|
||||
Type: proxyType,
|
||||
Enabled: &enabled,
|
||||
Annotations: map[string]string{"annotation-key": "annotation-value"},
|
||||
Metadatas: map[string]string{"metadata-key": "metadata-value"},
|
||||
Transport: v1.ProxyTransport{
|
||||
UseEncryption: true,
|
||||
UseCompression: true,
|
||||
BandwidthLimit: bandwidthLimit,
|
||||
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
|
||||
ProxyProtocolVersion: "v2",
|
||||
},
|
||||
LoadBalancer: v1.LoadBalancerConfig{
|
||||
Group: "public-group",
|
||||
GroupKey: "secret-group-key",
|
||||
},
|
||||
HealthCheck: v1.HealthCheckConfig{
|
||||
Type: "http",
|
||||
Path: "secret-health-path",
|
||||
},
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
LocalIP: "secret-local-host",
|
||||
LocalPort: 8080,
|
||||
Plugin: v1.TypedClientPluginOptions{
|
||||
Type: v1.PluginHTTPProxy,
|
||||
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
|
||||
Type: v1.PluginHTTPProxy,
|
||||
HTTPUser: "secret-plugin-user",
|
||||
HTTPPassword: "secret-plugin-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
var annotations map[string]string
|
||||
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
|
||||
t.Fatalf("unmarshal annotations failed: %v", err)
|
||||
}
|
||||
if annotations["annotation-key"] != "annotation-value" {
|
||||
t.Fatalf("annotations mismatch: %#v", annotations)
|
||||
}
|
||||
|
||||
var metadatas map[string]string
|
||||
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
|
||||
t.Fatalf("unmarshal metadatas failed: %v", err)
|
||||
}
|
||||
if metadatas["metadata-key"] != "metadata-value" {
|
||||
t.Fatalf("metadatas mismatch: %#v", metadatas)
|
||||
}
|
||||
|
||||
assertRawJSONKeysFromMessage(t, block["transport"],
|
||||
"bandwidthLimit",
|
||||
"bandwidthLimitMode",
|
||||
"useCompression",
|
||||
"useEncryption",
|
||||
)
|
||||
var transport model.V2ProxyTransportSpec
|
||||
if err := json.Unmarshal(block["transport"], &transport); err != nil {
|
||||
t.Fatalf("unmarshal transport failed: %v", err)
|
||||
}
|
||||
if !transport.UseEncryption || !transport.UseCompression ||
|
||||
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
|
||||
t.Fatalf("transport mismatch: %#v", transport)
|
||||
}
|
||||
|
||||
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
|
||||
var loadBalancer model.V2ProxyLoadBalancerSpec
|
||||
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
|
||||
t.Fatalf("unmarshal load balancer failed: %v", err)
|
||||
}
|
||||
if loadBalancer.Group != "public-group" {
|
||||
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
|
||||
forbidden := map[string]struct{}{
|
||||
"allowUsers": {},
|
||||
"enabled": {},
|
||||
"groupKey": {},
|
||||
"healthCheck": {},
|
||||
"httpPassword": {},
|
||||
"httpUser": {},
|
||||
"localIP": {},
|
||||
"localPort": {},
|
||||
"name": {},
|
||||
"natTraversal": {},
|
||||
"plugin": {},
|
||||
"proxyProtocolVersion": {},
|
||||
"requestHeaders": {},
|
||||
"responseHeaders": {},
|
||||
"secretKey": {},
|
||||
"type": {},
|
||||
}
|
||||
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
switch current := current.(type) {
|
||||
case map[string]any:
|
||||
for key, nested := range current {
|
||||
if _, ok := forbidden[key]; ok {
|
||||
t.Fatalf("sensitive field %q leaked in active block", key)
|
||||
}
|
||||
walk(nested)
|
||||
}
|
||||
case []any:
|
||||
for _, nested := range current {
|
||||
walk(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal active block failed: %v", err)
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode active block failed: %v", err)
|
||||
}
|
||||
walk(decoded)
|
||||
}
|
||||
@@ -20,13 +20,10 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
@@ -42,16 +39,10 @@ type v2EnvelopeForTest[T any] struct {
|
||||
}
|
||||
|
||||
type fakeStatsCollector struct {
|
||||
server *mem.ServerStats
|
||||
proxies map[string]*mem.ProxyStats
|
||||
traffic map[string]*mem.ProxyTrafficInfo
|
||||
pruneable map[string]bool
|
||||
proxies map[string]*mem.ProxyStats
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||
if f.server != nil {
|
||||
return f.server
|
||||
}
|
||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||
}
|
||||
|
||||
@@ -78,210 +69,13 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||
return f.traffic[name]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||
return 0, len(f.proxies)
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
||||
total := len(f.proxies)
|
||||
cleared := 0
|
||||
for name := range f.pruneable {
|
||||
if _, ok := f.proxies[name]; ok {
|
||||
delete(f.proxies, name)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
f.pruneable = map[string]bool{}
|
||||
return cleared, total
|
||||
}
|
||||
|
||||
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
server: &mem.ServerStats{
|
||||
TotalTrafficIn: 1024,
|
||||
TotalTrafficOut: 2048,
|
||||
CurConns: 3,
|
||||
ClientCounts: 4,
|
||||
ProxyTypeCounts: map[string]int64{
|
||||
"tcp": 2,
|
||||
"http": 1,
|
||||
},
|
||||
},
|
||||
proxies: map[string]*mem.ProxyStats{},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
VhostHTTPSPort: 8443,
|
||||
TCPMuxHTTPConnectPort: 9000,
|
||||
KCPBindPort: 7001,
|
||||
QUICBindPort: 7002,
|
||||
SubDomainHost: "example.com",
|
||||
MaxPortsPerClient: 8,
|
||||
AllowPorts: []types.PortsRange{
|
||||
{Start: 1000, End: 1002},
|
||||
{Single: 2000},
|
||||
},
|
||||
Transport: v1.ServerTransportConfig{
|
||||
MaxPoolCount: 5,
|
||||
HeartbeatTimeout: 90,
|
||||
TLS: v1.TLSServerConfig{
|
||||
Force: true,
|
||||
},
|
||||
},
|
||||
}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/system/info")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
||||
"allowPortsStr",
|
||||
"bindPort",
|
||||
"heartbeatTimeout",
|
||||
"kcpBindPort",
|
||||
"maxPoolCount",
|
||||
"maxPortsPerClient",
|
||||
"quicBindPort",
|
||||
"subdomainHost",
|
||||
"tcpmuxHTTPConnectPort",
|
||||
"tlsForce",
|
||||
"vhostHTTPPort",
|
||||
"vhostHTTPSPort",
|
||||
)
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
||||
"clientCounts",
|
||||
"curConns",
|
||||
"proxyTypeCount",
|
||||
"totalTrafficIn",
|
||||
"totalTrafficOut",
|
||||
)
|
||||
|
||||
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
||||
if systemResp.Data.Version == "" {
|
||||
t.Fatal("version should be set at top level")
|
||||
}
|
||||
if systemResp.Data.Config.BindPort != 7000 ||
|
||||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
||||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
||||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
||||
systemResp.Data.Config.KCPBindPort != 7001 ||
|
||||
systemResp.Data.Config.QUICBindPort != 7002 ||
|
||||
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
||||
systemResp.Data.Config.MaxPoolCount != 5 ||
|
||||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
||||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
||||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
||||
!systemResp.Data.Config.TLSForce {
|
||||
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
||||
}
|
||||
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
||||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
||||
systemResp.Data.Status.CurConns != 3 ||
|
||||
systemResp.Data.Status.ClientCounts != 4 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
||||
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
collector := &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
||||
"http-offline": {Name: "http-offline", Type: "http"},
|
||||
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
||||
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
||||
"http-online": {Name: "http-online", Type: "http"},
|
||||
"udp-online": {Name: "udp-online", Type: "udp"},
|
||||
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
||||
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
||||
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
||||
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
||||
},
|
||||
pruneable: map[string]bool{
|
||||
"tcp-offline": true,
|
||||
"http-offline": true,
|
||||
"udp-offline": true,
|
||||
},
|
||||
}
|
||||
mem.StatsCollector = collector
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
||||
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
||||
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-offline"]; ok {
|
||||
t.Fatal("pruned proxy statistics should be removed")
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-online"]; !ok {
|
||||
t.Fatal("online proxy statistics should remain")
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
||||
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
||||
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
||||
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
@@ -352,49 +146,10 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
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)
|
||||
}
|
||||
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
||||
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-url": {
|
||||
Name: "tcp-url",
|
||||
Type: "tcp",
|
||||
User: "url",
|
||||
ClientID: "client/a?b#c",
|
||||
CurConns: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
||||
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
encodedKey := url.PathEscape("url.client/a?b#c")
|
||||
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
||||
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
||||
}
|
||||
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
||||
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
@@ -416,194 +171,28 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||
}
|
||||
proxyItem := proxyResp.Data.Items[0]
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||
}
|
||||
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
||||
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
||||
var rawListSpec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
||||
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||
"curConns",
|
||||
"lastCloseAt",
|
||||
"lastStartAt",
|
||||
"phase",
|
||||
"todayTrafficIn",
|
||||
"todayTrafficOut",
|
||||
)
|
||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
||||
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
||||
}
|
||||
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
||||
var rawDetailSpec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
||||
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
||||
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
||||
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||
if userResp.Data.Total != 3 {
|
||||
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||
}
|
||||
expectedProxyCounts := map[string]int{
|
||||
"": 1,
|
||||
"alice": 2,
|
||||
"bob": 1,
|
||||
}
|
||||
for _, item := range userResp.Data.Items {
|
||||
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
||||
if item.ClientCount != 1 || item.ProxyCount != 1 {
|
||||
t.Fatalf("user counts mismatch: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"ssh": {Name: "ssh", Type: "tcp"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"ssh": {
|
||||
Name: "ssh",
|
||||
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
||||
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
||||
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
||||
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if len(trafficResp.Data.History) != 7 {
|
||||
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
||||
}
|
||||
|
||||
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
||||
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
||||
var prevDate time.Time
|
||||
for i, point := range trafficResp.Data.History {
|
||||
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
||||
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
||||
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
||||
}
|
||||
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
||||
if err != nil {
|
||||
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
||||
}
|
||||
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
||||
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
||||
}
|
||||
prevDate = parsedDate
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
||||
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
||||
name := "folder/ssh?x#y"
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
name: {Name: name, Type: "tcp", User: "encoded"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
name: {
|
||||
Name: name,
|
||||
TrafficIn: []int64{1},
|
||||
TrafficOut: []int64{2},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
encodedName := url.PathEscape(name)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
||||
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != name {
|
||||
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
||||
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
||||
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -613,85 +202,66 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "tcp remote port",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
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", Spec: model.V2ProxySpec{
|
||||
Type: "udp",
|
||||
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||
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", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
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", Spec: model.V2ProxySpec{
|
||||
Type: "http",
|
||||
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
||||
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", Spec: model.V2ProxySpec{
|
||||
Type: "https",
|
||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||
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", Spec: model.V2ProxySpec{
|
||||
Type: "https",
|
||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||
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", Spec: model.V2ProxySpec{
|
||||
Type: "tcpmux",
|
||||
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
||||
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: "offline shell does not match online spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{},
|
||||
}},
|
||||
name: "nil spec does not match spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||
q: "6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "offline shell does not contribute zero remote port",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{},
|
||||
}},
|
||||
q: "0",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -707,26 +277,7 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/serverinfo")
|
||||
var serverInfo model.ServerInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
||||
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if serverInfo.Version == "" {
|
||||
t.Fatal("legacy serverinfo version should be set")
|
||||
}
|
||||
var serverInfoRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
||||
}
|
||||
if _, ok := serverInfoRaw["data"]; ok {
|
||||
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
if _, ok := serverInfoRaw["config"]; ok {
|
||||
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/clients")
|
||||
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())
|
||||
@@ -747,28 +298,6 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||
var traffic model.GetProxyTrafficResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if traffic.Name != "tcp-alice" ||
|
||||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||
}
|
||||
var trafficRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||
}
|
||||
if _, ok := trafficRaw["data"]; ok {
|
||||
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func v2TestIntPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
@@ -793,18 +322,6 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-a",
|
||||
TodayTrafficIn: 30,
|
||||
TodayTrafficOut: 40,
|
||||
CurConns: 2,
|
||||
LastStartTime: "07-08 12:30:00",
|
||||
LastCloseTime: "07-08 12:31:40",
|
||||
LastStartAt: 1783504200,
|
||||
LastCloseAt: 1783504300,
|
||||
},
|
||||
"http-alice": {
|
||||
Name: "http-alice",
|
||||
Type: "http",
|
||||
User: "alice",
|
||||
ClientID: "client-a",
|
||||
CurConns: 3,
|
||||
},
|
||||
"udp-bob": {
|
||||
Name: "udp-bob",
|
||||
@@ -813,13 +330,6 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-b",
|
||||
},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"tcp-alice": {
|
||||
Name: "tcp-alice",
|
||||
TrafficIn: []int64{7, 6},
|
||||
TrafficOut: []int64{70, 60},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
@@ -831,34 +341,23 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
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())
|
||||
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/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
||||
encodedPathRouter := router.NewRoute().Subrouter()
|
||||
encodedPathRouter.UseEncodedPath()
|
||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).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)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/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)
|
||||
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
return performRequestWithMethod(handler, http.MethodGet, target)
|
||||
}
|
||||
|
||||
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
return resp
|
||||
@@ -873,36 +372,3 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if len(raw) != len(want) {
|
||||
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
|
||||
}
|
||||
for _, key := range want {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Fatalf("json key %q missing from %v", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
|
||||
}
|
||||
assertRawJSONKeys(t, out, want...)
|
||||
}
|
||||
|
||||
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
out, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -21,159 +21,26 @@ type V2PageResp[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
}
|
||||
|
||||
type V2SystemInfoResp struct {
|
||||
Version string `json:"version"`
|
||||
Config V2SystemInfoConfigResp `json:"config"`
|
||||
Status V2SystemInfoStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2SystemInfoConfigResp struct {
|
||||
BindPort int `json:"bindPort"`
|
||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||
KCPBindPort int `json:"kcpBindPort"`
|
||||
QUICBindPort int `json:"quicBindPort"`
|
||||
SubdomainHost string `json:"subdomainHost"`
|
||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||
AllowPortsStr string `json:"allowPortsStr"`
|
||||
TLSForce bool `json:"tlsForce"`
|
||||
}
|
||||
|
||||
type V2SystemInfoStatusResp struct {
|
||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ClientCounts int64 `json:"clientCounts"`
|
||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||
}
|
||||
|
||||
type V2SystemPruneResp struct {
|
||||
Type string `json:"type"`
|
||||
Cleared int `json:"cleared"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type V2UserResp struct {
|
||||
User string `json:"user"`
|
||||
ClientCount int `json:"clientCount"`
|
||||
ProxyCount int `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ClientDetailResp struct {
|
||||
ClientInfoResp
|
||||
Status V2ClientStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ClientStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ProxyCount int64 `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ProxyResp struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
ClientID string `json:"clientID"`
|
||||
Spec V2ProxySpec `json:"spec"`
|
||||
Spec any `json:"spec"`
|
||||
Status V2ProxyStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ProxySpec struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
||||
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
||||
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
||||
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
||||
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
||||
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
||||
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
||||
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyBaseSpec struct {
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
||||
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTransportSpec struct {
|
||||
UseEncryption bool `json:"useEncryption"`
|
||||
UseCompression bool `json:"useCompression"`
|
||||
BandwidthLimit string `json:"bandwidthLimit"`
|
||||
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
||||
}
|
||||
|
||||
type V2ProxyLoadBalancerSpec struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type V2TCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
RemotePort *int `json:"remotePort,omitempty"`
|
||||
}
|
||||
|
||||
type V2UDPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
RemotePort *int `json:"remotePort,omitempty"`
|
||||
}
|
||||
|
||||
type V2HTTPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
Locations []string `json:"locations,omitempty"`
|
||||
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||
}
|
||||
|
||||
type V2HTTPSProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
}
|
||||
|
||||
type V2TCPMuxProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
Multiplexer string `json:"multiplexer,omitempty"`
|
||||
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
||||
}
|
||||
|
||||
type V2STCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2SUDPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2XTCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2ProxyStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficResp struct {
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"`
|
||||
Granularity string `json:"granularity"`
|
||||
History []V2ProxyTrafficPointResp `json:"history"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficPointResp struct {
|
||||
Date string `json:"date"`
|
||||
TrafficIn int64 `json:"trafficIn"`
|
||||
TrafficOut int64 `json:"trafficOut"`
|
||||
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")
|
||||
}
|
||||
@@ -317,11 +317,38 @@ 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, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
|
||||
// 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")
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
pp "github.com/pires/go-proxyproto"
|
||||
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
"github.com/fatedier/frp/test/e2e/framework"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
||||
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||
@@ -456,85 +450,4 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
ExpectResp([]byte("test")).
|
||||
Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
||||
generator := &cert.SelfSignedCertGenerator{}
|
||||
artifacts, err := generator.Generate("example.com")
|
||||
framework.ExpectNoError(err)
|
||||
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
||||
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
||||
|
||||
serverConf := consts.DefaultServerConfig
|
||||
vhostHTTPSPort := f.AllocPort()
|
||||
serverConf += fmt.Sprintf(`
|
||||
vhostHTTPSPort = %d
|
||||
`, vhostHTTPSPort)
|
||||
|
||||
localPort := f.AllocPort()
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
[[proxies]]
|
||||
name = "tls2raw-proxy-protocol-test"
|
||||
type = "https"
|
||||
customDomains = ["example.com"]
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
[proxies.plugin]
|
||||
type = "tls2raw"
|
||||
localAddr = "127.0.0.1:%d"
|
||||
crtPath = "%s"
|
||||
keyPath = "%s"
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||
defer c.Close()
|
||||
|
||||
writeResp := func(body string) {
|
||||
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
||||
}
|
||||
|
||||
rd := bufio.NewReader(c)
|
||||
ppHeader, err := pp.Read(rd)
|
||||
if err != nil {
|
||||
log.Errorf("read proxy protocol error: %v", err)
|
||||
writeResp("missing proxy protocol")
|
||||
return
|
||||
}
|
||||
if ppHeader.Version != 2 {
|
||||
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
||||
writeResp("unexpected proxy protocol version")
|
||||
return
|
||||
}
|
||||
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
||||
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
||||
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
||||
writeResp("unexpected proxy protocol source address")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.ReadRequest(rd)
|
||||
if err != nil {
|
||||
log.Errorf("read http request after proxy protocol error: %v", err)
|
||||
writeResp("missing http request")
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
_ = req.Body.Close()
|
||||
|
||||
writeResp("test")
|
||||
}))
|
||||
f.RunServer("", localServer)
|
||||
|
||||
framework.NewRequestExpect(f).
|
||||
Port(vhostHTTPSPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
||||
ServerName: "example.com",
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
}).
|
||||
ExpectResp([]byte("test")).
|
||||
Ensure()
|
||||
})
|
||||
})
|
||||
|
||||
1
web/frps/components.d.ts
vendored
1
web/frps/components.d.ts
vendored
@@ -15,6 +15,7 @@ declare module 'vue' {
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
|
||||
@@ -24,7 +24,3 @@ export const getClientsV2 = (params: ClientListV2Params = {}) => {
|
||||
export const getClient = (key: string) => {
|
||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||
}
|
||||
|
||||
export const getClientV2 = (key: string) => {
|
||||
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
|
||||
}
|
||||
|
||||
@@ -98,13 +98,6 @@ export const http = {
|
||||
request<T>(url, { ...options, method: 'GET' }),
|
||||
getV2: <T>(url: string, options?: RequestInit) =>
|
||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
requestV2<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { buildQueryString, http } from './http'
|
||||
import { formatUnixSeconds } from '../utils/format'
|
||||
import type { V2Page } from './http'
|
||||
import type {
|
||||
GetProxyResponse,
|
||||
ProxyListV2Params,
|
||||
ProxyStatsInfo,
|
||||
ProxyV2Info,
|
||||
ProxyV2Spec,
|
||||
ProxyV2SpecBlocks,
|
||||
ProxyV2Type,
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
export interface SystemPruneResponse {
|
||||
type: 'offline_proxies'
|
||||
cleared: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const getProxiesByType = (type: string) => {
|
||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||
}
|
||||
@@ -42,77 +32,32 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getActiveProxySpec = (
|
||||
spec: ProxyV2Spec,
|
||||
): ProxyV2SpecBlocks[ProxyV2Type] => {
|
||||
switch (spec.type) {
|
||||
case 'tcp':
|
||||
return spec.tcp
|
||||
case 'udp':
|
||||
return spec.udp
|
||||
case 'http':
|
||||
return spec.http
|
||||
case 'https':
|
||||
return spec.https
|
||||
case 'tcpmux':
|
||||
return spec.tcpmux
|
||||
case 'stcp':
|
||||
return spec.stcp
|
||||
case 'sudp':
|
||||
return spec.sudp
|
||||
case 'xtcp':
|
||||
return spec.xtcp
|
||||
default:
|
||||
return assertNever(spec)
|
||||
}
|
||||
}
|
||||
|
||||
const assertNever = (value: never): never => {
|
||||
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
|
||||
const type = proxy.spec.type
|
||||
const activeSpec = getActiveProxySpec(proxy.spec)
|
||||
|
||||
return {
|
||||
name: proxy.name,
|
||||
type,
|
||||
conf: proxy.status.phase === 'offline' ? null : activeSpec,
|
||||
user: proxy.user,
|
||||
clientID: proxy.clientID,
|
||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
||||
curConns: proxy.status.curConns,
|
||||
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
|
||||
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
|
||||
status: proxy.status.phase,
|
||||
}
|
||||
}
|
||||
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}`)
|
||||
}
|
||||
|
||||
export const getProxyByNameV2 = async (name: string) => {
|
||||
const proxy = await http.getV2<ProxyV2Info>(
|
||||
`../api/v2/proxies/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return toLegacyProxyStats(proxy)
|
||||
}
|
||||
|
||||
export const getProxyByName = (name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyTraffic = (name: string) => {
|
||||
return http.getV2<TrafficResponse>(
|
||||
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
|
||||
)
|
||||
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
||||
}
|
||||
|
||||
export const clearOfflineProxies = () => {
|
||||
return http.postV2<SystemPruneResponse>(
|
||||
'../api/v2/system/prune?type=offline_proxies',
|
||||
)
|
||||
return http.delete('../api/proxies?status=offline')
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { http } from './http'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
export const getServerInfo = () => {
|
||||
return http.getV2<ServerInfo>('../api/v2/system/info')
|
||||
return http.get<ServerInfo>('../api/serverinfo')
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatFileSize } from '../utils/format'
|
||||
import { getProxyTraffic } from '../api/proxy'
|
||||
import type { TrafficResponse } from '../types/proxy'
|
||||
|
||||
const props = defineProps<{
|
||||
proxyName: string
|
||||
@@ -72,24 +71,41 @@ const chartData = ref<
|
||||
>([])
|
||||
const maxVal = ref(0)
|
||||
|
||||
const formatDateLabel = (date: string) => {
|
||||
const parts = date.split('-')
|
||||
if (parts.length !== 3) return date
|
||||
return `${Number(parts[1])}-${Number(parts[2])}`
|
||||
}
|
||||
const processData = (trafficIn: number[], trafficOut: number[]) => {
|
||||
// Ensure we have arrays and reverse them (server returns newest first)
|
||||
const inArr = [...(trafficIn || [])].reverse()
|
||||
const outArr = [...(trafficOut || [])].reverse()
|
||||
|
||||
const processData = (history: TrafficResponse['history'] = []) => {
|
||||
const points = history || []
|
||||
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
|
||||
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
|
||||
// Pad with zeros if less than 7 days
|
||||
while (inArr.length < 7) inArr.unshift(0)
|
||||
while (outArr.length < 7) outArr.unshift(0)
|
||||
|
||||
// Slice to last 7 entries just in case
|
||||
const finalIn = inArr.slice(-7)
|
||||
const finalOut = outArr.slice(-7)
|
||||
|
||||
// Calculate dates (last 7 days ending today)
|
||||
const dates: string[] = []
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
|
||||
d.setDate(d.getDate() + 1)
|
||||
}
|
||||
|
||||
// Find max value for scaling
|
||||
const maxIn = Math.max(...finalIn)
|
||||
const maxOut = Math.max(...finalOut)
|
||||
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||
|
||||
chartData.value = points.map((item) => ({
|
||||
date: formatDateLabel(item.date),
|
||||
in: item.trafficIn,
|
||||
out: item.trafficOut,
|
||||
inPercent: (item.trafficIn / maxVal.value) * 100,
|
||||
outPercent: (item.trafficOut / maxVal.value) * 100,
|
||||
// Build chart data
|
||||
chartData.value = dates.map((date, i) => ({
|
||||
date,
|
||||
in: finalIn[i],
|
||||
out: finalOut[i],
|
||||
inPercent: (finalIn[i] / maxVal.value) * 100,
|
||||
outPercent: (finalOut[i] / maxVal.value) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -97,7 +113,7 @@ const fetchData = () => {
|
||||
loading.value = true
|
||||
getProxyTraffic(props.proxyName)
|
||||
.then((json) => {
|
||||
processData(json.history)
|
||||
processData(json.trafficIn, json.trafficOut)
|
||||
})
|
||||
.catch((err) => {
|
||||
ElMessage({
|
||||
|
||||
@@ -7,17 +7,11 @@ export interface ClientInfoData {
|
||||
wireProtocol?: string
|
||||
hostname: string
|
||||
clientIP?: string
|
||||
metas?: Record<string, string>
|
||||
firstConnectedAt: number
|
||||
lastConnectedAt: number
|
||||
disconnectedAt?: number
|
||||
online: boolean
|
||||
status?: ClientStatus
|
||||
}
|
||||
|
||||
export interface ClientStatus {
|
||||
phase: 'online' | 'offline'
|
||||
curConns: number
|
||||
proxyCount: number
|
||||
}
|
||||
|
||||
export interface ClientListV2Params {
|
||||
|
||||
@@ -28,102 +28,24 @@ export interface ProxyListV2Params {
|
||||
|
||||
export interface ProxyV2Info {
|
||||
name: string
|
||||
type: string
|
||||
user: string
|
||||
clientID: string
|
||||
spec: ProxyV2Spec
|
||||
spec: any
|
||||
status: ProxyV2Status
|
||||
}
|
||||
|
||||
export interface ProxyV2BaseSpec {
|
||||
annotations?: Record<string, string>
|
||||
metadatas?: Record<string, string>
|
||||
transport?: {
|
||||
useEncryption: boolean
|
||||
useCompression: boolean
|
||||
bandwidthLimit: string
|
||||
bandwidthLimitMode: string
|
||||
}
|
||||
loadBalancer?: {
|
||||
group: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
|
||||
remotePort?: number
|
||||
}
|
||||
|
||||
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
|
||||
remotePort?: number
|
||||
}
|
||||
|
||||
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
|
||||
customDomains?: string[]
|
||||
subdomain?: string
|
||||
locations?: string[]
|
||||
hostHeaderRewrite?: string
|
||||
}
|
||||
|
||||
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
|
||||
customDomains?: string[]
|
||||
subdomain?: string
|
||||
}
|
||||
|
||||
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
|
||||
customDomains?: string[]
|
||||
subdomain?: string
|
||||
multiplexer?: string
|
||||
routeByHTTPUser?: string
|
||||
}
|
||||
|
||||
export type ProxyV2STCPBlock = ProxyV2BaseSpec
|
||||
|
||||
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
|
||||
|
||||
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
|
||||
|
||||
export interface ProxyV2SpecBlocks {
|
||||
tcp: ProxyV2TCPBlock
|
||||
udp: ProxyV2UDPBlock
|
||||
http: ProxyV2HTTPBlock
|
||||
https: ProxyV2HTTPSBlock
|
||||
tcpmux: ProxyV2TCPMuxBlock
|
||||
stcp: ProxyV2STCPBlock
|
||||
sudp: ProxyV2SUDPBlock
|
||||
xtcp: ProxyV2XTCPBlock
|
||||
}
|
||||
|
||||
export type ProxyV2Type = keyof ProxyV2SpecBlocks
|
||||
|
||||
type ProxyV2SpecFor<T extends ProxyV2Type> = {
|
||||
type: T
|
||||
} & {
|
||||
[K in T]: ProxyV2SpecBlocks[K]
|
||||
} & {
|
||||
[K in Exclude<ProxyV2Type, T>]?: never
|
||||
}
|
||||
|
||||
export type ProxyV2Spec = {
|
||||
[T in ProxyV2Type]: ProxyV2SpecFor<T>
|
||||
}[ProxyV2Type]
|
||||
|
||||
export interface ProxyV2Status {
|
||||
phase: 'online' | 'offline'
|
||||
phase: string
|
||||
todayTrafficIn: number
|
||||
todayTrafficOut: number
|
||||
curConns: number
|
||||
lastStartAt?: number
|
||||
lastCloseAt?: number
|
||||
lastStartTime: string
|
||||
lastCloseTime: string
|
||||
}
|
||||
|
||||
export interface TrafficResponse {
|
||||
name: string
|
||||
unit: 'bytes'
|
||||
granularity: 'day'
|
||||
history: TrafficPoint[]
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
date: string
|
||||
trafficIn: number
|
||||
trafficOut: number
|
||||
trafficIn: number[]
|
||||
trafficOut: number[]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export interface ServerInfo {
|
||||
version: string
|
||||
config: ServerInfoConfig
|
||||
status: ServerInfoStatus
|
||||
}
|
||||
|
||||
export interface ServerInfoConfig {
|
||||
bindPort: number
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
@@ -17,9 +12,8 @@ export interface ServerInfoConfig {
|
||||
heartbeatTimeout: number
|
||||
allowPortsStr: string
|
||||
tlsForce: boolean
|
||||
}
|
||||
|
||||
export interface ServerInfoStatus {
|
||||
// Stats
|
||||
totalTrafficIn: number
|
||||
totalTrafficOut: number
|
||||
curConns: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistanceToNow } from './format'
|
||||
import type { ClientInfoData, ClientStatus } from '../types/client'
|
||||
import type { ClientInfoData } from '../types/client'
|
||||
|
||||
export class Client {
|
||||
key: string
|
||||
@@ -10,11 +10,11 @@ export class Client {
|
||||
wireProtocol: string
|
||||
hostname: string
|
||||
ip: string
|
||||
metas: Map<string, string>
|
||||
firstConnectedAt: Date
|
||||
lastConnectedAt: Date
|
||||
disconnectedAt?: Date
|
||||
online: boolean
|
||||
status: ClientStatus
|
||||
|
||||
constructor(data: ClientInfoData) {
|
||||
this.key = data.key
|
||||
@@ -25,17 +25,18 @@ export class Client {
|
||||
this.wireProtocol = data.wireProtocol || ''
|
||||
this.hostname = data.hostname
|
||||
this.ip = data.clientIP || ''
|
||||
this.metas = new Map<string, string>()
|
||||
if (data.metas) {
|
||||
for (const [key, value] of Object.entries(data.metas)) {
|
||||
this.metas.set(key, value)
|
||||
}
|
||||
}
|
||||
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
|
||||
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
|
||||
if (data.disconnectedAt && data.disconnectedAt > 0) {
|
||||
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
||||
}
|
||||
this.online = data.online
|
||||
this.status = data.status || {
|
||||
phase: this.online ? 'online' : 'offline',
|
||||
curConns: 0,
|
||||
proxyCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
get displayName(): string {
|
||||
@@ -45,6 +46,10 @@ export class Client {
|
||||
return this.runID
|
||||
}
|
||||
|
||||
get shortRunId(): string {
|
||||
return this.runID.substring(0, 8)
|
||||
}
|
||||
|
||||
get wireProtocolLabel(): string {
|
||||
if (!this.wireProtocol) return ''
|
||||
return `Protocol ${this.wireProtocol}`
|
||||
@@ -62,4 +67,28 @@ export class Client {
|
||||
if (!this.disconnectedAt) return ''
|
||||
return formatDistanceToNow(this.disconnectedAt)
|
||||
}
|
||||
|
||||
get statusColor(): string {
|
||||
return this.online ? 'success' : 'danger'
|
||||
}
|
||||
|
||||
get metasArray(): Array<{ key: string; value: string }> {
|
||||
const arr: Array<{ key: string; value: string }> = []
|
||||
this.metas.forEach((value, key) => {
|
||||
arr.push({ key, value })
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
matchesFilter(searchText: string): boolean {
|
||||
const search = searchText.toLowerCase()
|
||||
return (
|
||||
this.key.toLowerCase().includes(search) ||
|
||||
this.user.toLowerCase().includes(search) ||
|
||||
this.clientID.toLowerCase().includes(search) ||
|
||||
this.runID.toLowerCase().includes(search) ||
|
||||
this.wireProtocol.toLowerCase().includes(search) ||
|
||||
this.hostname.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ export function formatDistanceToNow(date: Date): string {
|
||||
return Math.floor(seconds) + ' seconds ago'
|
||||
}
|
||||
|
||||
export function formatUnixSeconds(seconds?: number): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Connections</span>
|
||||
<span class="info-value">{{ client.status.curConns }}</span>
|
||||
<span class="info-value">{{ totalConnections }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Run ID</span>
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="proxies-header">
|
||||
<div class="proxies-title">
|
||||
<h2>Proxies</h2>
|
||||
<span class="proxies-count">{{ total }}</span>
|
||||
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="proxySearch"
|
||||
@@ -100,32 +100,21 @@
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div v-else-if="proxies.length > 0" class="proxies-list">
|
||||
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in proxies"
|
||||
:key="`${proxy.type}:${proxy.name}`"
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
show-type
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="proxySearch.trim()" class="empty-state">
|
||||
<div v-else-if="clientProxies.length > 0" class="empty-state">
|
||||
<p>No proxies match "{{ proxySearch }}"</p>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No proxies found</p>
|
||||
</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>
|
||||
|
||||
@@ -141,13 +130,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||
import { Client } from '../utils/client'
|
||||
import { getClientV2 } from '../api/client'
|
||||
import { getProxiesV2 } from '../api/proxy'
|
||||
import { getClient } from '../api/client'
|
||||
import { getProxiesByType } from '../api/proxy'
|
||||
import {
|
||||
BaseProxy,
|
||||
TCPProxy,
|
||||
@@ -160,8 +149,6 @@ import {
|
||||
} from '../utils/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -176,191 +163,119 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
const proxiesLoading = ref(false)
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const allProxies = ref<BaseProxy[]>([])
|
||||
const proxySearch = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
let requestSeq = 0
|
||||
let searchDebounceTimer: number | null = null
|
||||
|
||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
const clientProxies = computed(() => {
|
||||
if (!client.value) return []
|
||||
return allProxies.value.filter(
|
||||
(p) =>
|
||||
p.clientID === client.value!.clientID && p.user === client.value!.user,
|
||||
)
|
||||
})
|
||||
|
||||
const filteredProxies = computed(() => {
|
||||
if (!proxySearch.value) return clientProxies.value
|
||||
const search = proxySearch.value.toLowerCase()
|
||||
return clientProxies.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
const totalConnections = computed(() => {
|
||||
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
|
||||
})
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
}
|
||||
|
||||
const fetchClient = async (): Promise<boolean> => {
|
||||
const fetchClient = async () => {
|
||||
const key = route.params.key as string
|
||||
if (!key) {
|
||||
loading.value = false
|
||||
return false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getClientV2(key)
|
||||
const data = await getClient(key)
|
||||
client.value = new Client(data)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
ElMessage.error('Failed to fetch client: ' + error.message)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const convertProxy = async (
|
||||
proxy: ProxyStatsInfo,
|
||||
): Promise<BaseProxy | null> => {
|
||||
const type = proxy.type || ''
|
||||
if (type === 'tcp') {
|
||||
return new TCPProxy(proxy)
|
||||
}
|
||||
if (type === 'udp') {
|
||||
return new UDPProxy(proxy)
|
||||
}
|
||||
if (type === 'http') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPPort) {
|
||||
return new HTTPProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPSPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.config.tcpmuxHTTPConnectPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'stcp') {
|
||||
return new STCPProxy(proxy)
|
||||
}
|
||||
if (type === 'sudp') {
|
||||
return new SUDPProxy(proxy)
|
||||
}
|
||||
|
||||
const bp = new BaseProxy(proxy)
|
||||
bp.type = type
|
||||
return bp
|
||||
}
|
||||
|
||||
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 fetchProxies = async () => {
|
||||
if (!client.value) return
|
||||
const seq = ++requestSeq
|
||||
proxiesLoading.value = true
|
||||
|
||||
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
||||
const proxies: BaseProxy[] = []
|
||||
try {
|
||||
const q = proxySearch.value.trim()
|
||||
const data = await getProxiesV2({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
q: q || undefined,
|
||||
clientID: client.value.clientID,
|
||||
user: client.value.user,
|
||||
})
|
||||
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 fetchProxies()
|
||||
return
|
||||
const info = await fetchServerInfo()
|
||||
for (const type of proxyTypes) {
|
||||
try {
|
||||
const json = await getProxiesByType(type)
|
||||
if (!json.proxies) continue
|
||||
if (type === 'tcp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
|
||||
} else if (type === 'udp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||
),
|
||||
)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||
),
|
||||
)
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new TCPMuxProxy(
|
||||
p,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
|
||||
} else if (type === 'sudp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
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.error('Failed to fetch proxies: ' + error.message)
|
||||
allProxies.value = proxies
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearchDebounce = () => {
|
||||
if (searchDebounceTimer !== null) {
|
||||
window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateProxyRequests = () => {
|
||||
requestSeq++
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
|
||||
const resetPageAndFetch = () => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
const onPageChange = (value: number) => {
|
||||
clearSearchDebounce()
|
||||
page.value = value
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
const onPageSizeChange = (value: number) => {
|
||||
pageSize.value = value
|
||||
resetPageAndFetch()
|
||||
}
|
||||
|
||||
watch(proxySearch, () => {
|
||||
clearSearchDebounce()
|
||||
invalidateProxyRequests()
|
||||
page.value = 1
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
fetchProxies()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSearchDebounce()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const ok = await fetchClient()
|
||||
if (!ok || !client.value) return
|
||||
|
||||
onMounted(() => {
|
||||
fetchClient()
|
||||
fetchProxies()
|
||||
})
|
||||
</script>
|
||||
@@ -568,12 +483,6 @@ html.dark .status-badge.online {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.proxies-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -27,6 +27,36 @@
|
||||
clearable
|
||||
class="main-search"
|
||||
/>
|
||||
|
||||
<PopoverMenu
|
||||
:model-value="selectedClientKey"
|
||||
:width="220"
|
||||
placement="bottom-end"
|
||||
selectable
|
||||
filterable
|
||||
filter-placeholder="Search clients..."
|
||||
:display-value="selectedClientLabel"
|
||||
clearable
|
||||
class="client-filter"
|
||||
@update:model-value="onClientFilterChange($event as string)"
|
||||
>
|
||||
<template #default="{ filterText }">
|
||||
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
v-if="clientIDFilter && !selectedClientInList"
|
||||
:value="selectedClientKey"
|
||||
>
|
||||
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
v-for="client in filteredClientOptions(filterText)"
|
||||
:key="client.key"
|
||||
:value="client.key"
|
||||
>
|
||||
{{ client.label }}
|
||||
</PopoverMenuItem>
|
||||
</template>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
|
||||
<div class="type-tabs">
|
||||
@@ -81,7 +111,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
@@ -98,13 +128,16 @@ import {
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||
import {
|
||||
getProxiesV2,
|
||||
clearOfflineProxies as apiClearOfflineProxies,
|
||||
} from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import { getClientsV2 } from '../api/client'
|
||||
import { Client } from '../utils/client'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -121,22 +154,115 @@ const proxyTypes = [
|
||||
{ label: 'SUDP', value: 'sudp' },
|
||||
]
|
||||
|
||||
const activeType = ref((route.params.type as string) || 'all')
|
||||
const activeType = ref((route.params.type as string) || 'tcp')
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const loading = ref(false)
|
||||
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
|
||||
.map((c) => ({
|
||||
key: c.key,
|
||||
clientID: c.clientID,
|
||||
user: c.user,
|
||||
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
})
|
||||
|
||||
// Compute selected client key for el-select v-model
|
||||
const selectedClientKey = computed(() => {
|
||||
if (!clientIDFilter.value) return ''
|
||||
const client = clientOptions.value.find(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
// Return a synthetic key even if not found, so the select shows the filter is active
|
||||
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
||||
})
|
||||
|
||||
const selectedClientLabel = computed(() => {
|
||||
if (!clientIDFilter.value) return 'All Clients'
|
||||
const client = clientOptions.value.find(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
|
||||
})
|
||||
|
||||
const filteredClientOptions = (filterText: string) => {
|
||||
if (!filterText) return clientOptions.value
|
||||
const search = filterText.toLowerCase()
|
||||
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
// Check if the filtered client exists in the client list
|
||||
const selectedClientInList = computed(() => {
|
||||
if (!clientIDFilter.value) return true
|
||||
return clientOptions.value.some(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
})
|
||||
|
||||
const onClientFilterChange = (key: string) => {
|
||||
if (key) {
|
||||
const client = clientOptions.value.find((c) => c.key === key)
|
||||
if (client) {
|
||||
router.replace({
|
||||
query: { ...route.query, clientID: client.clientID, user: client.user },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const query = { ...route.query }
|
||||
delete query.clientID
|
||||
delete query.user
|
||||
router.replace({ query })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
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 - cache the Promise itself so concurrent first calls
|
||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||
type ServerInfoLite = {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
}
|
||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||
|
||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
// Allow retry after failure
|
||||
@@ -159,33 +285,25 @@ const convertProxy = async (
|
||||
}
|
||||
if (type === 'http') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPPort) {
|
||||
return new HTTPProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
if (info && info.vhostHTTPPort) {
|
||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPSPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
if (info && info.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||
if (info && info.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.config.tcpmuxHTTPConnectPort,
|
||||
info.config.subdomainHost,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -219,6 +337,8 @@ const fetchData = async (silent = false) => {
|
||||
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
|
||||
|
||||
@@ -299,18 +419,6 @@ const clearOfflineProxies = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeClientQuery = () => {
|
||||
const hasClientQuery =
|
||||
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
|
||||
Object.prototype.hasOwnProperty.call(route.query, 'user')
|
||||
if (!hasClientQuery) return
|
||||
|
||||
const query = { ...route.query }
|
||||
delete query.clientID
|
||||
delete query.user
|
||||
router.replace({ query })
|
||||
}
|
||||
|
||||
// Watch for type changes
|
||||
watch(activeType, (newType) => {
|
||||
clearSearchDebounce()
|
||||
@@ -329,15 +437,23 @@ watch(searchText, () => {
|
||||
}, 300)
|
||||
})
|
||||
|
||||
watch(() => route.query, sanitizeClientQuery)
|
||||
// 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
|
||||
sanitizeClientQuery()
|
||||
fetchData()
|
||||
fetchClients()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -404,11 +520,16 @@ fetchData()
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-search :deep(.el-input__wrapper) {
|
||||
.main-search :deep(.el-input__wrapper),
|
||||
.client-filter :deep(.el-input__wrapper) {
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -463,6 +584,10 @@ fetchData()
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ import {
|
||||
Tickets,
|
||||
Location,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getProxyByNameV2 } from '../api/proxy'
|
||||
import { getProxyByName } from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import {
|
||||
BaseProxy,
|
||||
@@ -254,7 +254,6 @@ import {
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import Traffic from '../components/Traffic.vue'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -276,7 +275,12 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
|
||||
let serverInfo: ServerInfo | null = null
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const clientLink = computed(() => {
|
||||
if (!proxy.value) return ''
|
||||
@@ -361,32 +365,27 @@ const fetchProxy = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getProxyByNameV2(name)
|
||||
const data = await getProxyByName(name)
|
||||
const info = await fetchServerInfo()
|
||||
const config = info.config
|
||||
const type = data.type || data.conf?.type || ''
|
||||
const type = data.conf?.type || ''
|
||||
|
||||
if (type === 'tcp') {
|
||||
proxy.value = new TCPProxy(data)
|
||||
} else if (type === 'udp') {
|
||||
proxy.value = new UDPProxy(data)
|
||||
} else if (type === 'http' && config.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(
|
||||
data,
|
||||
config.vhostHTTPPort,
|
||||
config.subdomainHost,
|
||||
)
|
||||
} else if (type === 'https' && config.vhostHTTPSPort) {
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
proxy.value = new HTTPSProxy(
|
||||
data,
|
||||
config.vhostHTTPSPort,
|
||||
config.subdomainHost,
|
||||
info.vhostHTTPSPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
proxy.value = new TCPMuxProxy(
|
||||
data,
|
||||
config.tcpmuxHTTPConnectPort,
|
||||
config.subdomainHost,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxy.value = new STCPProxy(data)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Clients"
|
||||
:value="data.status.clientCounts"
|
||||
:value="data.clientCounts"
|
||||
type="clients"
|
||||
subtitle="Connected clients"
|
||||
to="/clients"
|
||||
@@ -13,7 +13,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Proxies"
|
||||
:value="proxyCounts"
|
||||
:value="data.proxyCounts"
|
||||
type="proxies"
|
||||
subtitle="Active proxies"
|
||||
to="/proxies/tcp"
|
||||
@@ -22,7 +22,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Connections"
|
||||
:value="data.status.curConns"
|
||||
:value="data.curConns"
|
||||
type="connections"
|
||||
subtitle="Current connections"
|
||||
/>
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="traffic-info">
|
||||
<div class="label">Inbound</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.status.totalTrafficIn) }}
|
||||
{{ formatFileSize(data.totalTrafficIn) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="traffic-info">
|
||||
<div class="label">Outbound</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.status.totalTrafficOut) }}
|
||||
{{ formatFileSize(data.totalTrafficOut) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
</template>
|
||||
<div class="proxy-types-grid">
|
||||
<div
|
||||
v-for="(count, type) in data.status.proxyTypeCount"
|
||||
v-for="(count, type) in data.proxyTypeCounts"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
v-show="count > 0"
|
||||
@@ -109,51 +109,51 @@
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<span class="config-label">Bind Port</span>
|
||||
<span class="config-value">{{ data.config.bindPort }}</span>
|
||||
<span class="config-value">{{ data.bindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.kcpBindPort != 0">
|
||||
<div class="config-item" v-if="data.kcpBindPort != 0">
|
||||
<span class="config-label">KCP Port</span>
|
||||
<span class="config-value">{{ data.config.kcpBindPort }}</span>
|
||||
<span class="config-value">{{ data.kcpBindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.quicBindPort != 0">
|
||||
<div class="config-item" v-if="data.quicBindPort != 0">
|
||||
<span class="config-label">QUIC Port</span>
|
||||
<span class="config-value">{{ data.config.quicBindPort }}</span>
|
||||
<span class="config-value">{{ data.quicBindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
|
||||
<div class="config-item" v-if="data.vhostHTTPPort != 0">
|
||||
<span class="config-label">HTTP Port</span>
|
||||
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
|
||||
<span class="config-value">{{ data.vhostHTTPPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
|
||||
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
|
||||
<span class="config-label">HTTPS Port</span>
|
||||
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
|
||||
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
|
||||
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
|
||||
<span class="config-label">TCPMux Port</span>
|
||||
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
|
||||
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.subdomainHost != ''">
|
||||
<div class="config-item" v-if="data.subdomainHost != ''">
|
||||
<span class="config-label">Subdomain Host</span>
|
||||
<span class="config-value">{{ data.config.subdomainHost }}</span>
|
||||
<span class="config-value">{{ data.subdomainHost }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Max Pool Count</span>
|
||||
<span class="config-value">{{ data.config.maxPoolCount }}</span>
|
||||
<span class="config-value">{{ data.maxPoolCount }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Max Ports/Client</span>
|
||||
<span class="config-value">{{ maxPortsPerClientLabel }}</span>
|
||||
<span class="config-value">{{ data.maxPortsPerClient }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.allowPortsStr != ''">
|
||||
<div class="config-item" v-if="data.allowPortsStr != ''">
|
||||
<span class="config-label">Allow Ports</span>
|
||||
<span class="config-value">{{ data.config.allowPortsStr }}</span>
|
||||
<span class="config-value">{{ data.allowPortsStr }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.tlsForce">
|
||||
<div class="config-item" v-if="data.tlsForce">
|
||||
<span class="config-label">TLS Force</span>
|
||||
<el-tag size="small" type="warning">Enabled</el-tag>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Heartbeat Timeout</span>
|
||||
<span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
|
||||
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -167,59 +167,69 @@ import { formatFileSize } from '../utils/format'
|
||||
import { Download, Upload } from '@element-plus/icons-vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const data = ref<ServerInfo>({
|
||||
const data = ref({
|
||||
version: '',
|
||||
config: {
|
||||
bindPort: 0,
|
||||
kcpBindPort: 0,
|
||||
quicBindPort: 0,
|
||||
vhostHTTPPort: 0,
|
||||
vhostHTTPSPort: 0,
|
||||
tcpmuxHTTPConnectPort: 0,
|
||||
subdomainHost: '',
|
||||
maxPoolCount: 0,
|
||||
maxPortsPerClient: 0,
|
||||
allowPortsStr: '',
|
||||
tlsForce: false,
|
||||
heartbeatTimeout: 0,
|
||||
},
|
||||
status: {
|
||||
clientCounts: 0,
|
||||
curConns: 0,
|
||||
totalTrafficIn: 0,
|
||||
totalTrafficOut: 0,
|
||||
proxyTypeCount: {},
|
||||
},
|
||||
bindPort: 0,
|
||||
kcpBindPort: 0,
|
||||
quicBindPort: 0,
|
||||
vhostHTTPPort: 0,
|
||||
vhostHTTPSPort: 0,
|
||||
tcpmuxHTTPConnectPort: 0,
|
||||
subdomainHost: '',
|
||||
maxPoolCount: 0,
|
||||
maxPortsPerClient: '',
|
||||
allowPortsStr: '',
|
||||
tlsForce: false,
|
||||
heartbeatTimeout: 0,
|
||||
clientCounts: 0,
|
||||
curConns: 0,
|
||||
proxyCounts: 0,
|
||||
totalTrafficIn: 0,
|
||||
totalTrafficOut: 0,
|
||||
proxyTypeCounts: {} as Record<string, number>,
|
||||
})
|
||||
|
||||
const hasActiveProxies = computed(() => {
|
||||
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
|
||||
})
|
||||
|
||||
const proxyCounts = computed(() => {
|
||||
return Object.values(data.value.status.proxyTypeCount).reduce(
|
||||
(sum, count) => sum + (count || 0),
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const maxPortsPerClientLabel = computed(() => {
|
||||
const value = data.value.config.maxPortsPerClient
|
||||
return value === 0 ? 'no limit' : String(value)
|
||||
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
|
||||
})
|
||||
|
||||
const formatTrafficTotal = () => {
|
||||
const total =
|
||||
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
|
||||
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
|
||||
return formatFileSize(total)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const json = await getServerInfo()
|
||||
data.value = json
|
||||
data.value.version = json.version
|
||||
data.value.bindPort = json.bindPort
|
||||
data.value.kcpBindPort = json.kcpBindPort
|
||||
data.value.quicBindPort = json.quicBindPort
|
||||
data.value.vhostHTTPPort = json.vhostHTTPPort
|
||||
data.value.vhostHTTPSPort = json.vhostHTTPSPort
|
||||
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
||||
data.value.subdomainHost = json.subdomainHost
|
||||
data.value.maxPoolCount = json.maxPoolCount
|
||||
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
|
||||
if (data.value.maxPortsPerClient == '0') {
|
||||
data.value.maxPortsPerClient = 'no limit'
|
||||
}
|
||||
data.value.allowPortsStr = json.allowPortsStr
|
||||
data.value.tlsForce = json.tlsForce
|
||||
data.value.heartbeatTimeout = json.heartbeatTimeout
|
||||
data.value.clientCounts = json.clientCounts
|
||||
data.value.curConns = json.curConns
|
||||
data.value.totalTrafficIn = json.totalTrafficIn
|
||||
data.value.totalTrafficOut = json.totalTrafficOut
|
||||
data.value.proxyTypeCounts = json.proxyTypeCount || {}
|
||||
|
||||
data.value.proxyCounts = 0
|
||||
if (json.proxyTypeCount != null) {
|
||||
Object.values(json.proxyTypeCount).forEach((count: any) => {
|
||||
data.value.proxyCounts += count || 0
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
|
||||
279
web/package-lock.json
generated
279
web/package-lock.json
generated
@@ -147,13 +147,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -163,13 +164,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -179,13 +181,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -195,13 +198,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -211,13 +215,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -227,13 +232,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -243,13 +249,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -259,13 +266,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -275,13 +283,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -291,13 +300,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -307,13 +317,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -323,13 +334,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -339,13 +351,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -355,13 +368,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -371,13 +385,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -387,13 +402,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -403,13 +419,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -419,13 +436,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -435,13 +453,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -451,13 +470,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -467,13 +487,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -483,13 +504,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -499,13 +521,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -515,13 +538,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -531,13 +555,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -547,13 +572,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2471,9 +2497,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -3354,11 +3380,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -3366,32 +3393,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
@@ -4762,20 +4789,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -6188,10 +6206,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -7371,12 +7390,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
Reference in New Issue
Block a user