Compare commits

..

31 Commits
dev ... master

Author SHA1 Message Date
fatedier
7b6e01f04f Merge pull request #5411 from fatedier/dev
Release v0.70.0
2026-07-11 18:50:35 +08:00
fatedier
5396947c7c remove retired Go Report Card badge (#5409) 2026-07-11 15:59:51 +08:00
fatedier
fe61093bc2 bump version to 0.70.0 (#5406) 2026-07-11 14:28:19 +08:00
fatedier
54aeb2a7b0 feat(server): add typed frps v2 proxy specs (#5405) 2026-07-11 10:34:04 +08:00
fatedier
84be1938e4 api: expose v2 proxy timestamps as unix seconds (#5402) 2026-07-09 14:47:19 +08:00
fatedier
becee40715 docs: update API v2 release notes (#5400) 2026-07-08 15:54:16 +08:00
fatedier
17e788d43b refactor: clean up frps v2 frontend models (#5399) 2026-07-08 13:10:55 +08:00
fatedier
68509f5d44 Add frps proxy traffic API v2 (#5398) 2026-07-08 02:05:28 +08:00
fatedier
5cd722b177 feat: add system prune API v2 (#5395) 2026-07-07 13:00:53 +08:00
fatedier
5876beceac feat: add system info API v2 (#5394) 2026-07-07 02:00:44 +08:00
fatedier
7fe152e3aa web/frps: use API v2 for client and proxy details (#5386) 2026-06-30 01:33:57 +08:00
fatedier
7c343fc6e7 web: bump esbuild to 0.28.1 and remove unused ElPopover type (#5385) 2026-06-29 23:21:22 +08:00
fatedier
a3b3b35b69 feat(ui): default proxies view to all tab (#5384) 2026-06-29 22:49:08 +08:00
fatedier
ae1c0504ec feat(dashboard): add v2 client detail status (#5381) 2026-06-26 21:15:30 +08:00
fatedier
393a533744 docs: add release note for duplicate config names (#5379) 2026-06-24 15:03:22 +08:00
MAAZIZ Adel Ayoub
035889c360 fix(client): reject duplicate proxy and visitor names (#5378) 2026-06-24 13:37:29 +08:00
fatedier
940bde5c46 docs: update release notes (#5377) 2026-06-23 17:06:16 +08:00
fatedier
14628df63c test: cover tls2raw proxy protocol header (#5376) 2026-06-23 00:04:38 +08:00
Shani Pathak
ba7adcab8f fix(websocket): send tunnel payload as binary frames (#5363)
The ws/wss transport carries a raw byte stream (yamux), but the
golang.org/x/net/websocket Conn defaults to text frames (PayloadType
TextFrame). Per RFC 6455 §5.6 a text frame must contain valid UTF-8, so
RFC-compliant intermediaries (API gateways / reverse proxies) validate
the payload and close the connection when the binary tunnel data is not
valid UTF-8.

This goes unnoticed peer-to-peer because x/net/websocket does not
validate UTF-8 on read, but it breaks the connection through a compliant
validating proxy. Set PayloadType to BinaryFrame on both the server
listener and the client dialer so the tunnel is framed as binary.
2026-06-22 23:34:02 +08:00
kaixings
4cc826e236 fix(client): write proxy protocol header in tls2raw plugin (#5362)
Co-authored-by: futrobo <futrobo@163.com>
2026-06-22 23:08:50 +08:00
fatedier
54c6ccdfec feat: remove proxies client filter (#5375) 2026-06-22 22:53:57 +08:00
fatedier
8dd26c6961 Merge pull request #5350 from fatedier/dev
Release v0.69.1
2026-06-01 18:02:22 +08:00
fatedier
c8c1e5116c Merge pull request #5323 from fatedier/dev
Release v0.69.0
2026-05-22 00:55:23 +08:00
fatedier
4ec8de973f Merge pull request #5287 from fatedier/dev
bump version to v0.68.1
2026-04-14 01:28:33 +08:00
fatedier
5bfcea3d0c merge dev to master (#5254)
* ci: bump github actions to latest major versions (#5251)

* docker: copy shared web directory for npm workspace builds
2026-03-20 15:54:26 +08:00
fatedier
0a1b4ab21f Merge pull request #5249 from fatedier/dev
bump version
2026-03-20 13:56:28 +08:00
fatedier
5f575b8442 Merge pull request #5147 from fatedier/dev
bump version
2026-01-31 14:01:40 +08:00
fatedier
a1348cdf00 bump version (#5112) 2026-01-04 14:54:13 +08:00
fatedier
2f5e1f7945 Merge pull request #4999 from fatedier/dev
bump version
2025-09-25 20:23:42 +08:00
fatedier
22ae8166d3 Merge pull request #4925 from fatedier/dev
bump version
2025-08-10 23:26:32 +08:00
fatedier
af6bc6369d Merge pull request #4849 from fatedier/dev
bump version
2025-06-25 11:51:19 +08:00
93 changed files with 2623 additions and 2994 deletions

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
* text=auto eol=lf

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [fatedier]
custom: ["https://afdian.com/a/fatedier"]

3
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,3 @@
### WHY
<!-- author to complete -->

View File

@@ -1,194 +0,0 @@
name: Build FRP Binaries
on:
push:
branches:
- '**'
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin, freebsd, openbsd, android]
goarch: [amd64, 386, arm, arm64]
include:
- goos: linux
goarch: loong64
exclude:
- goos: darwin
goarch: arm
- goos: darwin
goarch: 386
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: android
goarch: amd64
- goos: android
goarch: 386
# 排除 Android ARM 32位,在单独的 job 中处理
- goos: android
goarch: arm
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y zip tar make gcc g++ upx
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
run: |
mkdir -p release/packages
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
# 构建版本号
make
version=$(./bin/frps --version)
echo "Detected version: $version"
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
export CGO_ENABLED=0
# 构建可执行文件
make frpc frps
if [ "${{ matrix.goos }}" = "windows" ]; then
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
fi
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
mkdir -p "$out_dir"
if [ "${{ matrix.goos }}" = "windows" ]; then
mv ./bin/frpc.exe "$out_dir/frpc.exe"
mv ./bin/frps.exe "$out_dir/frps.exe"
else
mv ./bin/frpc "$out_dir/frpc"
mv ./bin/frps "$out_dir/frps"
fi
cp LICENSE "$out_dir"
cp -f conf/frpc.toml "$out_dir"
cp -f conf/frps.toml "$out_dir"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
path: |
release/packages/frp_*
retention-days: 7
build-android-arm:
name: Build FRP android-arm
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies and Android NDK
run: |
sudo apt-get update -y
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
# 下载并安装 Android NDK
echo "Downloading Android NDK..."
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
echo "Extracting Android NDK..."
unzip -q android-ndk-r26c-linux.zip
echo "NDK installed at: $PWD/android-ndk-r26c"
- name: Build FRP for android-arm
run: |
mkdir -p release/packages
mkdir -p bin
echo "Building for android-arm with CGO"
# 首先构建一次获取版本号
CGO_ENABLED=0 make
version=$(./bin/frps --version)
echo "Detected version: $version"
# 清理之前的构建
rm -rf ./bin/*
# 设置 Android ARM 交叉编译环境
export GOOS=android
export GOARCH=arm
export GOARM=7
export CGO_ENABLED=1
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
echo "Environment:"
echo "GOOS=$GOOS"
echo "GOARCH=$GOARCH"
echo "GOARM=$GOARM"
echo "CGO_ENABLED=$CGO_ENABLED"
echo "CC=$CC"
# 直接使用 go build 命令,不通过 Makefile防止 CGO_ENABLED 被覆盖
# -checklinkname=0: 关闭 Go 1.23+ 的 linkname 检查,规避 wlynxg/anet
# 在 android 下通过 //go:linkname 引用 net.zoneCache 导致的链接失败
echo "Building frps..."
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frps -o bin/frps ./cmd/frps
echo "Building frpc..."
go build -trimpath -ldflags "-s -w -checklinkname=0" -tags frpc -o bin/frpc ./cmd/frpc
# 验证文件已生成
ls -lh ./bin/
file ./bin/frpc
file ./bin/frps
out_dir="release/packages/frp_${version}_android_arm"
mkdir -p "$out_dir"
mv ./bin/frpc "$out_dir/frpc"
mv ./bin/frps "$out_dir/frps"
cp LICENSE "$out_dir"
cp -f conf/frpc.toml "$out_dir"
cp -f conf/frps.toml "$out_dir"
echo "Build completed for android-arm"
ls -lh "$out_dir"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: LoliaFrp_android_arm
path: |
release/packages/frp_*
retention-days: 7

View File

@@ -0,0 +1,83 @@
name: Build Image and Publish to Dockerhub & GPR
on:
release:
types: [ published ]
workflow_dispatch:
inputs:
tag:
description: 'Image tag'
required: true
default: 'test'
permissions:
contents: read
jobs:
image:
name: Build Image from Dockerfile and binaries
runs-on: ubuntu-latest
steps:
# environment
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: '0'
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# get image tag name
- name: Get Image Tag Name
run: |
if [ x${{ github.event.inputs.tag }} == x"" ]; then
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
else
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
fi
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to the GPR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GPR_TOKEN }}
# prepare image tags
- name: Prepare Image Tags
run: |
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
- name: Build and push frpc
uses: docker/build-push-action@v7
with:
context: .
file: ./dockerfiles/Dockerfile-for-frpc
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
push: true
tags: |
${{ env.TAG_FRPC }}
${{ env.TAG_FRPC_GPR }}
- name: Build and push frps
uses: docker/build-push-action@v7
with:
context: .
file: ./dockerfiles/Dockerfile-for-frps
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
push: true
tags: |
${{ env.TAG_FRPS }}
${{ env.TAG_FRPS_GPR }}

View File

@@ -1,82 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- dev
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
include:
- name: frps
dockerfile: dockerfiles/Dockerfile-for-frps
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
- name: frpc
dockerfile: dockerfiles/Dockerfile-for-frpc
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image_name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Generate image digest
if: github.event_name != 'pull_request'
run: |
echo "Image pushed to: ${{ matrix.image_name }}"
echo "Tags: ${{ steps.meta.outputs.tags }}"

View File

@@ -1,129 +0,0 @@
name: Release FRP Binaries
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to release (e.g., v1.0.0)"
required: true
type: string
permissions:
contents: write
jobs:
# 调用 build-all workflow
build:
uses: ./.github/workflows/build-all.yaml
permissions:
contents: read
# 创建 release
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get tag name
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display artifact structure
run: |
echo "Artifact structure:"
ls -R artifacts/
- name: Organize release files
run: |
mkdir -p release_files
# 查找并复制所有压缩包
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
# 如果没有压缩包,尝试查找二进制文件并打包
if [ -z "$(ls -A release_files/)" ]; then
echo "No archives found, looking for directories to package..."
for dir in artifacts/*/; do
if [ -d "$dir" ]; then
artifact_name=$(basename "$dir")
echo "Packaging $artifact_name"
# 检查是否是 Windows 构建
if echo "$artifact_name" | grep -q "windows"; then
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
else
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
fi
fi
done
fi
echo "Files in release_files:"
ls -lh release_files/
- name: Generate checksums
run: |
cd release_files
if [ -n "$(ls -A .)" ]; then
sha256sum * > sha256sum.txt
cat sha256sum.txt
else
echo "No files to generate checksums for!"
exit 1
fi
- name: Debug - Check tags and commits
run: |
echo "Current tag: ${{ steps.tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
echo "Previous tag: $PREV_TAG"
echo ""
echo "Commits between tags:"
if [ "$PREV_TAG" != "none" ]; then
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
else
echo "First tag, showing all commits:"
git log --oneline ${{ steps.tag.outputs.tag }}
fi
- name: Build Changelog
id: changelog
uses: requarks/changelog-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.tag.outputs.tag }}
writeToFile: false
includeInvalidCommits: true
useGitmojis: false
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Release ${{ steps.tag.outputs.tag }}
body: ${{ steps.changelog.outputs.changes }}
draft: false
prerelease: false
files: |
release_files/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -32,10 +32,6 @@ node_modules/
*.swp *.swp
# AI # AI
CLAUDE.md
.claude/ .claude/
.sisyphus/ .sisyphus/
.superpowers/ .superpowers/
# TLS
.autotls-cache

View File

@@ -1,8 +1,6 @@
export PATH := $(PATH):`go env GOPATH`/bin export PATH := $(PATH):`go env GOPATH`/bin
export GO111MODULE=on export GO111MODULE=on
# -checklinkname=0: required since Go 1.23+ for github.com/wlynxg/anet (pion dep), LDFLAGS := -s -w
# which uses //go:linkname to reference net.zoneCache on android targets.
LDFLAGS := -s -w -checklinkname=0
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb') NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
FRP_COMPAT_BASELINE_COUNT ?= 8 FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0 FRP_COMPAT_FLOOR_VERSION ?= 0.61.0

View File

@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp) [![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases) [![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp) [![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md) [README](README.md) | [中文文档](README_zh.md)
@@ -13,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3> <h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start--> <!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center"> <p align="center">
<a href="https://jb.gg/frp" target="_blank"> <a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg"> <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -30,16 +39,6 @@ frp is an open source project with its ongoing development made possible entirel
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub> <sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a> </a>
</p> </p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end--> <!--gold sponsors end-->
## What is frp? ## What is frp?

View File

@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3> <h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start--> <!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center"> <p align="center">
<a href="https://jb.gg/frp" target="_blank"> <a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg"> <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub> <sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a> </a>
</p> </p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end--> <!--gold sponsors end-->
## 为什么使用 frp ## 为什么使用 frp

View File

@@ -1,9 +1,9 @@
## Features ## Features
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol. * 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.
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
## Compatibility Notes ## Fixes
* 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. * 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.

View File

@@ -16,9 +16,7 @@ package client
import ( import (
"context" "context"
"fmt"
"net" "net"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -162,44 +160,9 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName) proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error) err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
if err != nil { if err != nil {
xl.Warnf("[%s] 启动失败: %v", proxyName, err) xl.Warnf("[%s] start error: %v", proxyName, err)
} else { } else {
xl.Infof("[%s] 成功启动隧道", proxyName) xl.Infof("[%s] start proxy success", proxyName)
if inMsg.RemoteAddr != "" {
// Get proxy type to format access message
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
proxyType := status.Type
remoteAddr := inMsg.RemoteAddr
var accessMsg string
switch proxyType {
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
if strings.HasPrefix(remoteAddr, ":") {
serverAddr := ctl.sessionCtx.Common.ServerAddr
remoteAddr = serverAddr + remoteAddr
}
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
case "http", "https":
// Format as URL with protocol
protocol := proxyType
addr := remoteAddr
// Remove standard ports for cleaner URL
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
addr = strings.TrimSuffix(addr, ":80")
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
addr = strings.TrimSuffix(addr, ":443")
}
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
default:
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
}
xl.Infof("[%s] %s", proxyName, accessMsg)
} else {
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
}
}
} }
} }

View File

@@ -20,9 +20,7 @@ import (
"io" "io"
"net" "net"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -72,7 +70,6 @@ func NewProxy(
baseProxy := BaseProxy{ baseProxy := BaseProxy{
baseCfg: pxyConf.GetBaseConfig(), baseCfg: pxyConf.GetBaseConfig(),
configurer: pxyConf,
clientCfg: clientCfg, clientCfg: clientCfg,
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
limiter: limiter, limiter: limiter,
@@ -91,7 +88,6 @@ func NewProxy(
type BaseProxy struct { type BaseProxy struct {
baseCfg *v1.ProxyBaseConfig baseCfg *v1.ProxyBaseConfig
configurer v1.ProxyConfigurer
clientCfg *v1.ClientCommonConfig clientCfg *v1.ClientCommonConfig
encryptionKey []byte encryptionKey []byte
msgTransporter transport.MessageTransporter msgTransporter transport.MessageTransporter
@@ -111,7 +107,6 @@ func (pxy *BaseProxy) Run() error {
if pxy.baseCfg.Plugin.Type != "" { if pxy.baseCfg.Plugin.Type != "" {
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{ p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
Name: pxy.baseCfg.Name, Name: pxy.baseCfg.Name,
HostAllowList: pxy.getPluginHostAllowList(),
VnetController: pxy.vnetController, VnetController: pxy.vnetController,
}, pxy.baseCfg.Plugin.ClientPluginOptions) }, pxy.baseCfg.Plugin.ClientPluginOptions)
if err != nil { if err != nil {
@@ -122,39 +117,6 @@ func (pxy *BaseProxy) Run() error {
return nil return nil
} }
func (pxy *BaseProxy) getPluginHostAllowList() []string {
dedupHosts := make([]string, 0)
addHost := func(host string) {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return
}
// autocert.HostWhitelist only supports exact host names.
if strings.Contains(host, "*") {
return
}
if !slices.Contains(dedupHosts, host) {
dedupHosts = append(dedupHosts, host)
}
}
switch cfg := pxy.configurer.(type) {
case *v1.HTTPProxyConfig:
for _, host := range cfg.CustomDomains {
addHost(host)
}
case *v1.HTTPSProxyConfig:
for _, host := range cfg.CustomDomains {
addHost(host)
}
case *v1.TCPMuxProxyConfig:
for _, host := range cfg.CustomDomains {
addHost(host)
}
}
return dedupHosts
}
func (pxy *BaseProxy) Close() { func (pxy *BaseProxy) Close() {
if pxy.proxyPlugin != nil { if pxy.proxyPlugin != nil {
pxy.proxyPlugin.Close() pxy.proxyPlugin.Close()

View File

@@ -159,7 +159,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
} }
} }
if len(delPxyNames) > 0 { if len(delPxyNames) > 0 {
xl.Infof("隧道移除: %s", delPxyNames) xl.Infof("proxy removed: %s", delPxyNames)
} }
addPxyNames := make([]string, 0) addPxyNames := make([]string, 0)
@@ -177,6 +177,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
} }
} }
if len(addPxyNames) > 0 { if len(addPxyNames) > 0 {
xl.Infof("添加隧道: %s", addPxyNames) xl.Infof("proxy added: %s", addPxyNames)
} }
} }

View File

@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
xl := pxy.xl xl := pxy.xl
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String()) xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
// close resources related with old workConn // close resources related with old workConn
pxy.Close() pxy.Close()

View File

@@ -260,7 +260,7 @@ func (svr *Service) Run(ctx context.Context) error {
cancelCause := cancelErr{} cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause) _ = errors.As(context.Cause(svr.ctx), &cancelCause)
svr.stop() svr.stop()
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err) return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
} }
go svr.keepControllerWorking() go svr.keepControllerWorking()
@@ -305,7 +305,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
xl := xlog.FromContextSafe(svr.ctx) xl := xlog.FromContextSafe(svr.ctx)
loginFunc := func() (bool, error) { loginFunc := func() (bool, error) {
xl.Infof("尝试连接到服务器...") xl.Infof("try to connect to server...")
dialer := &controlSessionDialer{ dialer := &controlSessionDialer{
ctx: svr.ctx, ctx: svr.ctx,
common: svr.common, common: svr.common,
@@ -316,7 +316,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
} }
sessionCtx, err := dialer.Dial(svr.runID) sessionCtx, err := dialer.Dial(svr.runID)
if err != nil { if err != nil {
xl.Warnf("连接服务器错误: %v", err) xl.Warnf("connect to server error: %v", err)
if firstLoginExit { if firstLoginExit {
svr.cancel(cancelErr{Err: err}) svr.cancel(cancelErr{Err: err})
} }

View File

@@ -54,11 +54,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
Use: name, Use: name,
Short: short, Short: short,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if len(cfgFiles) == 0 || cfgFiles[0] == "" { cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
fmt.Println("frpc: the configuration file is not specified")
os.Exit(1)
}
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)

View File

@@ -48,19 +48,8 @@ var natholeDiscoveryCmd = &cobra.Command{
Short: "Discover nathole information from stun server", Short: "Discover nathole information from stun server",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// ignore error here, because we can use command line parameters // ignore error here, because we can use command line parameters
var cfg *v1.ClientCommonConfig cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if len(cfgFiles) > 0 && cfgFiles[0] != "" { if err != nil {
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
if err != nil {
cfg = &v1.ClientCommonConfig{}
if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err)
os.Exit(1)
}
} else {
cfg = loaded
}
} else {
cfg = &v1.ClientCommonConfig{} cfg = &v1.ClientCommonConfig{}
if err := cfg.Complete(); err != nil { if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err) fmt.Printf("failed to complete config: %v\n", err)

View File

@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "") err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "") err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -141,13 +141,11 @@ func startService(
visitorCfgs []v1.VisitorConfigurer, visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures, unsafeFeatures *security.UnsafeFeatures,
cfgFile string, cfgFile string,
nodeName string,
tunnelRemark string,
) error { ) error {
configSource := source.NewConfigSource() configSource := source.NewConfigSource()
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil { if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
return fmt.Errorf("failed to set config source: %w", err) return fmt.Errorf("failed to set config source: %w", err)
} }
aggregator := source.NewAggregator(configSource) aggregator := source.NewAggregator(configSource)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark) return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
} }

View File

@@ -16,11 +16,8 @@ package sub
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -38,28 +35,24 @@ import (
"github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/featuregate" "github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security" "github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/banner"
"github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/version"
) )
var ( var (
cfgFiles []string cfgFile string
cfgDir string cfgDir string
showVersion bool showVersion bool
strictConfigMode bool strictConfigMode bool
allowUnsafe []string allowUnsafe []string
authTokens []string
bannerDisplayed bool
) )
func init() { func init() {
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)") rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
rootCmd.PersistentFlags().StringSliceVarP(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", "))) fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
} }
@@ -75,30 +68,15 @@ var rootCmd = &cobra.Command{
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
// If authTokens is provided, fetch config from API
if len(authTokens) > 0 {
err := runClientWithTokens(authTokens, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
return nil
}
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir. // If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
// Note that it's only designed for testing. It's not guaranteed to be stable.
if cfgDir != "" { if cfgDir != "" {
_ = runMultipleClients(cfgDir, unsafeFeatures) _ = runMultipleClients(cfgDir, unsafeFeatures)
return nil return nil
} }
// If multiple config files are specified, run one frpc service for each file
if len(cfgFiles) > 1 {
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
return nil
}
// Do not show command usage here. // Do not show command usage here.
err := runClient(cfgFiles[0], unsafeFeatures) err := runClient(cfgFile, unsafeFeatures)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -128,29 +106,6 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
return err return err
} }
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
var wg sync.WaitGroup
// Display banner first
banner.DisplayBanner()
bannerDisplayed = true
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
for _, cfgFile := range cfgFiles {
wg.Add(1)
// Add a small delay to avoid log output mixing
time.Sleep(100 * time.Millisecond)
go func(path string) {
defer wg.Done()
err := runClient(path, unsafeFeatures)
if err != nil {
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
}
}(cfgFile)
}
wg.Wait()
}
func Execute() { func Execute() {
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc) rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
@@ -231,7 +186,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
return err return err
} }
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "") return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
} }
func startServiceWithAggregator( func startServiceWithAggregator(
@@ -239,25 +194,12 @@ func startServiceWithAggregator(
aggregator *source.Aggregator, aggregator *source.Aggregator,
unsafeFeatures *security.UnsafeFeatures, unsafeFeatures *security.UnsafeFeatures,
cfgFile string, cfgFile string,
nodeName string,
tunnelRemark string,
) error { ) error {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
// Display banner only once before starting the first service
if !bannerDisplayed {
banner.DisplayBanner()
bannerDisplayed = true
}
// Display node information if available
if nodeName != "" {
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
}
if cfgFile != "" { if cfgFile != "" {
log.Infof("启动 frpc 服务 [%s]", cfgFile) log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile) defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
} }
svr, err := client.NewService(client.ServiceOptions{ svr, err := client.NewService(client.ServiceOptions{
Common: cfg, Common: cfg,
@@ -275,189 +217,3 @@ func startServiceWithAggregator(
} }
return svr.Run(context.Background()) return svr.Run(context.Background())
} }
// APIResponse represents the response from LoliaFRP API
type APIResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Config string `json:"config"`
NodeName string `json:"node_name"`
TunnelRemark string `json:"tunnel_remark"`
} `json:"data"`
}
// TokenInfo stores parsed id and token from the -t parameter
type TokenInfo struct {
ID string
Token string
}
func runClientWithTokens(tokens []string, unsafeFeatures *security.UnsafeFeatures) error {
// Parse all tokens (format: id:token)
tokenInfos := make([]TokenInfo, 0, len(tokens))
for _, t := range tokens {
parts := strings.SplitN(t, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid token format '%s', expected 'id:token'", t)
}
tokenInfos = append(tokenInfos, TokenInfo{
ID: strings.TrimSpace(parts[0]),
Token: strings.TrimSpace(parts[1]),
})
}
// Group tokens by token value (same token can have multiple IDs)
tokenToIDs := make(map[string][]string)
for _, ti := range tokenInfos {
tokenToIDs[ti.Token] = append(tokenToIDs[ti.Token], ti.ID)
}
// If we have multiple different tokens, start one service for each token group
if len(tokenToIDs) > 1 {
return runMultipleClientsWithTokens(tokenToIDs, unsafeFeatures)
}
// Get the single token and all its IDs
var token string
var ids []string
for t, idList := range tokenToIDs {
token = t
ids = idList
break
}
return runClientWithTokenAndIDs(token, ids, unsafeFeatures)
}
func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *security.UnsafeFeatures) error {
// Get API server address from environment variable
apiServer := os.Getenv("LOLIA_API")
if apiServer == "" {
apiServer = "https://api.lolia.link"
}
// Build URL with query parameters
url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ","))
// URL is constructed from trusted source (environment variable or hardcoded)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create API request: %v", err)
}
// Carry client version in User-Agent so the API knows which frpc version is requesting
req.Header.Set("User-Agent", version.Full())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch config from API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return fmt.Errorf("failed to decode API response: %v", err)
}
if apiResp.Code != 200 {
return fmt.Errorf("API error: %s", apiResp.Msg)
}
// Decode base64 config
configBytes, err := base64.StdEncoding.DecodeString(apiResp.Data.Config)
if err != nil {
return fmt.Errorf("failed to decode base64 config: %v", err)
}
// Load config directly from bytes
return runClientWithConfig(configBytes, unsafeFeatures, apiResp.Data.NodeName, apiResp.Data.TunnelRemark)
}
func runMultipleClientsWithTokens(tokenToIDs map[string][]string, unsafeFeatures *security.UnsafeFeatures) error {
var wg sync.WaitGroup
// Display banner first
banner.DisplayBanner()
bannerDisplayed = true
log.Infof("检测到 %d 个不同的 token将并行启动多个 frpc 服务实例", len(tokenToIDs))
index := 0
for token, ids := range tokenToIDs {
wg.Add(1)
currentIndex := index
currentToken := token
currentIDs := ids
totalCount := len(tokenToIDs)
// Add a small delay to avoid log output mixing
time.Sleep(100 * time.Millisecond)
go func() {
defer wg.Done()
maskedToken := currentToken
if len(maskedToken) > 6 {
maskedToken = maskedToken[:3] + "***" + maskedToken[len(maskedToken)-3:]
} else {
maskedToken = "***"
}
log.Infof("[%d/%d] 启动 token: %s (IDs: %v)", currentIndex+1, totalCount, maskedToken, currentIDs)
err := runClientWithTokenAndIDs(currentToken, currentIDs, unsafeFeatures)
if err != nil {
fmt.Printf("\nToken [%s] 启动失败: %v\n", maskedToken, err)
}
}()
index++
}
wg.Wait()
return nil
}
func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeatures, nodeName, tunnelRemark string) error {
// Render template first
renderedBytes, err := config.RenderWithTemplate(configBytes, config.GetValues())
if err != nil {
return fmt.Errorf("failed to render template: %v", err)
}
var allCfg v1.ClientConfig
if err := config.LoadConfigure(renderedBytes, &allCfg, strictConfigMode); err != nil {
return fmt.Errorf("failed to parse config: %v", err)
}
cfg := &allCfg.ClientCommonConfig
proxyCfgs := make([]v1.ProxyConfigurer, 0, len(allCfg.Proxies))
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
visitorCfgs := make([]v1.VisitorConfigurer, 0, len(allCfg.Visitors))
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
// Call Complete to fill in default values
if err := cfg.Complete(); err != nil {
return fmt.Errorf("failed to complete config: %v", err)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
if len(cfg.FeatureGates) > 0 {
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
return err
}
}
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
if err != nil {
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, "", nodeName, tunnelRemark)
}

View File

@@ -33,12 +33,11 @@ var verifyCmd = &cobra.Command{
Use: "verify", Use: "verify",
Short: "Verify that the configures is valid", Short: "Verify that the configures is valid",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if len(cfgFiles) == 0 || cfgFiles[0] == "" { if cfgFile == "" {
fmt.Println("frpc: the configuration file is not specified") fmt.Println("frpc: the configuration file is not specified")
return nil return nil
} }
cfgFile := cfgFiles[0]
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)

View File

@@ -275,10 +275,6 @@ localIP = "127.0.0.1"
localPort = 8000 localPort = 8000
subdomain = "web02" subdomain = "web02"
customDomains = ["web02.yourdomain.com"] customDomains = ["web02.yourdomain.com"]
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
# on the same domain always takes precedence over the redirect.
# httpRedirect = true
# if not empty, frpc will use proxy protocol to transfer connection info to your local service # if not empty, frpc will use proxy protocol to transfer connection info to your local service
# v1 or v2 or empty # v1 or v2 or empty
transport.proxyProtocolVersion = "v2" transport.proxyProtocolVersion = "v2"
@@ -340,14 +336,6 @@ type = "https2http"
localAddr = "127.0.0.1:80" localAddr = "127.0.0.1:80"
crtPath = "./server.crt" crtPath = "./server.crt"
keyPath = "./server.key" keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
hostHeaderRewrite = "127.0.0.1" hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp" requestHeaders.set.x-from-where = "frp"
@@ -360,14 +348,6 @@ type = "https2https"
localAddr = "127.0.0.1:443" localAddr = "127.0.0.1:443"
crtPath = "./server.crt" crtPath = "./server.crt"
keyPath = "./server.key" keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
hostHeaderRewrite = "127.0.0.1" hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp" requestHeaders.set.x-from-where = "frp"
@@ -381,15 +361,6 @@ localAddr = "127.0.0.1:443"
hostHeaderRewrite = "127.0.0.1" hostHeaderRewrite = "127.0.0.1"
requestHeaders.set.x-from-where = "frp" requestHeaders.set.x-from-where = "frp"
[[proxies]]
name = "plugin_http2https_redirect"
type = "http"
customDomains = ["test.yourdomain.com"]
[proxies.plugin]
type = "http2https_redirect"
# Optional. Defaults to 443. Set this if the HTTPS entry is exposed on a non-standard port.
# httpsPort = 443
[[proxies]] [[proxies]]
name = "plugin_http2http" name = "plugin_http2http"
type = "tcp" type = "tcp"
@@ -409,14 +380,6 @@ type = "tls2raw"
localAddr = "127.0.0.1:80" localAddr = "127.0.0.1:80"
crtPath = "./server.crt" crtPath = "./server.crt"
keyPath = "./server.key" keyPath = "./server.key"
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
# [proxies.plugin.autoTLS]
# enable = true
# email = "admin@example.com"
# cacheDir = "./.autotls-cache"
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
# hostAllowList = ["test.yourdomain.com"]
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
[[proxies]] [[proxies]]
name = "secret_tcp" name = "secret_tcp"

View File

@@ -40,12 +40,6 @@ transport.maxPoolCount = 5
# If negative, keep-alive probes are disabled. # If negative, keep-alive probes are disabled.
# transport.tcpKeepalive = 7200 # transport.tcpKeepalive = 7200
# proxyIdleTimeout specifies the maximum time in seconds that a proxied user connection
# can stay open without any traffic in either direction before frps closes it.
# It reclaims connections whose endpoints are still open at the TCP level but will never send data again.
# By default, this value is 0, which disables the idle timeout.
# transport.proxyIdleTimeout = 7200
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false. # transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
transport.tls.force = false transport.tls.force = false
@@ -58,12 +52,6 @@ transport.tls.force = false
vhostHTTPPort = 80 vhostHTTPPort = 80
vhostHTTPSPort = 443 vhostHTTPSPort = 443
# Port used in the Location header when redirecting HTTP requests to HTTPS for
# proxies with httpRedirect enabled. Set it when browsers reach the HTTPS vhost
# through a port mapping, so it differs from vhostHTTPSPort.
# By default, this value is vhostHTTPSPort.
# vhostHTTPSRedirectPort = 443
# Response header timeout(seconds) for vhost http server, default is 60s # Response header timeout(seconds) for vhost http server, default is 60s
# vhostHTTPTimeout = 60 # vhostHTTPTimeout = 60

18
go.mod
View File

@@ -4,8 +4,6 @@ go 1.25.0
require ( require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/fatedier/golib v0.7.0 github.com/fatedier/golib v0.7.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -43,16 +41,10 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
@@ -61,10 +53,6 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/klauspost/reedsolomon v1.12.0 // indirect github.com/klauspost/reedsolomon v1.12.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/logging v0.2.4 // indirect github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect github.com/pion/transport/v4 v4.0.1 // indirect
@@ -73,7 +61,6 @@ require (
github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/templexxx/cpu v0.1.1 // indirect github.com/templexxx/cpu v0.1.1 // indirect
github.com/templexxx/xorsimd v0.4.3 // indirect github.com/templexxx/xorsimd v0.4.3 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
@@ -81,9 +68,7 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/vishvananda/netns v0.0.4 // indirect github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.42.0 // indirect
@@ -97,6 +82,3 @@ require (
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository. // TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
// Use the Lolia-FRP fork of golib: io.Join relays with adaptively sized buffers.
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.0.0-20260704205217-7f676961e707

37
go.sum
View File

@@ -2,29 +2,13 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Lolia-FRP/golib v0.0.0-20260704205217-7f676961e707 h1:ROQVjgk+RvaN8J8uR8ekUu4TKgJLXYObVeQC/0KADn4=
github.com/Lolia-FRP/golib v0.0.0-20260704205217-7f676961e707/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
@@ -36,12 +20,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -86,15 +70,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
@@ -128,9 +105,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA= github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE= github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -172,8 +148,6 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk= github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
@@ -188,8 +162,6 @@ golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -217,7 +189,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View File

@@ -18,7 +18,7 @@ rm -rf ./release/packages
mkdir -p ./release/packages mkdir -p ./release/packages
os_all='linux windows darwin freebsd openbsd android' os_all='linux windows darwin freebsd openbsd android'
arch_all='amd64 arm arm64' arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
extra_all='_ hf' extra_all='_ hf'
cd ./release cd ./release

View File

@@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days") cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate") cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default") cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
} }
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")

View File

@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
} }
} }
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
return nil, err
}
return result, nil return result, nil
} }
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
} }
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
// keyed by name in the config sources, so a duplicate would otherwise be silently
// overwritten and never started, with no error or log.
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
proxyNames := make(map[string]struct{}, len(proxies))
for _, p := range proxies {
name := p.GetBaseConfig().Name
if _, ok := proxyNames[name]; ok {
return fmt.Errorf("proxy name [%s] is duplicated", name)
}
proxyNames[name] = struct{}{}
}
visitorNames := make(map[string]struct{}, len(visitors))
for _, v := range visitors {
name := v.GetBaseConfig().Name
if _, ok := visitorNames[name]; ok {
return fmt.Errorf("visitor name [%s] is duplicated", name)
}
visitorNames[name] = struct{}{}
}
return nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer { func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies proxyCfgs := proxies
for _, c := range proxyCfgs { for _, c := range proxyCfgs {

View File

@@ -17,6 +17,8 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
require.Equal("keep", proxies[0].GetBaseConfig().Name) require.Equal("keep", proxies[0].GetBaseConfig().Name)
} }
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
tests := []struct {
name string
content string
errSubstr string
}{
{
name: "duplicate proxy names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
errSubstr: "proxy name [dup] is duplicated",
},
{
name: "duplicate visitor names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[visitors]]
name = "dup"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
[[visitors]]
name = "dup"
type = "stcp"
serverName = "b"
secretKey = "secret"
bindPort = 9002
`,
errSubstr: "visitor name [dup] is duplicated",
},
{
name: "unique names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "p1"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "p2"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
},
{
name: "same name across proxy and visitor",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "same"
type = "tcp"
localPort = 22
remotePort = 6000
[[visitors]]
name = "same"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
_, err := LoadClientConfigResult(path, false)
if tc.errSubstr == "" {
require.NoError(err)
} else {
require.ErrorContains(err, tc.errSubstr)
}
})
}
}
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types // TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
func TestYAMLEdgeCases(t *testing.T) { func TestYAMLEdgeCases(t *testing.T) {
require := require.New(t) require := require.New(t)

View File

@@ -49,8 +49,7 @@ type ClientCommonConfig struct {
// STUN server to help penetrate NAT hole. // STUN server to help penetrate NAT hole.
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"` NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
// DNSServer specifies a DNS server address for FRPC to use. If this value // DNSServer specifies a DNS server address for FRPC to use. If this value
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also // is "", the default DNS will be used.
// supported, e.g. "https://1.1.1.1/dns-query".
DNSServer string `json:"dnsServer,omitempty"` DNSServer string `json:"dnsServer,omitempty"`
// LoginFailExit controls whether or not the client should exit after a // LoginFailExit controls whether or not the client should exit after a
// failed login attempt. If false, the client will retry until a login // failed login attempt. If false, the client will retry until a login

View File

@@ -369,12 +369,6 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct { type HTTPSProxyConfig struct {
ProxyBaseConfig ProxyBaseConfig
DomainConfig DomainConfig
// HTTPRedirect requests frps to redirect plain HTTP requests for the
// proxy's domains to their HTTPS endpoint. It only takes effect when
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
// proxy registered on the same domain.
HTTPRedirect bool `json:"httpRedirect,omitempty"`
} }
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) { func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
@@ -382,7 +376,6 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
m.CustomDomains = c.CustomDomains m.CustomDomains = c.CustomDomains
m.SubDomain = c.SubDomain m.SubDomain = c.SubDomain
m.HTTPRedirect = c.HTTPRedirect
} }
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
@@ -390,7 +383,6 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.CustomDomains = m.CustomDomains c.CustomDomains = m.CustomDomains
c.SubDomain = m.SubDomain c.SubDomain = m.SubDomain
c.HTTPRedirect = m.HTTPRedirect
} }
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer { func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {

View File

@@ -16,7 +16,6 @@ package v1
import ( import (
"reflect" "reflect"
"slices"
"github.com/samber/lo" "github.com/samber/lo"
@@ -25,31 +24,29 @@ import (
) )
const ( const (
PluginHTTP2HTTPS = "http2https" PluginHTTP2HTTPS = "http2https"
PluginHTTP2HTTPSRedirect = "http2https_redirect" PluginHTTPProxy = "http_proxy"
PluginHTTPProxy = "http_proxy" PluginHTTPS2HTTP = "https2http"
PluginHTTPS2HTTP = "https2http" PluginHTTPS2HTTPS = "https2https"
PluginHTTPS2HTTPS = "https2https" PluginHTTP2HTTP = "http2http"
PluginHTTP2HTTP = "http2http" PluginSocks5 = "socks5"
PluginSocks5 = "socks5" PluginStaticFile = "static_file"
PluginStaticFile = "static_file" PluginUnixDomainSocket = "unix_domain_socket"
PluginUnixDomainSocket = "unix_domain_socket" PluginTLS2Raw = "tls2raw"
PluginTLS2Raw = "tls2raw" PluginVirtualNet = "virtual_net"
PluginVirtualNet = "virtual_net"
) )
var clientPluginOptionsTypeMap = map[string]reflect.Type{ var clientPluginOptionsTypeMap = map[string]reflect.Type{
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](), PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](), PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](), PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](), PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](), PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](), PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](), PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](), PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](), PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](), PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
} }
type ClientPluginOptions interface { type ClientPluginOptions interface {
@@ -83,29 +80,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.ClientPluginOptions) return jsonx.Marshal(c.ClientPluginOptions)
} }
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
// that terminate TLS locally.
type AutoTLSOptions struct {
Enable bool `json:"enable,omitempty"`
// Contact email for certificate expiration and important notices.
Email string `json:"email,omitempty"`
// Directory used to cache ACME account and certificates.
CacheDir string `json:"cacheDir,omitempty"`
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
CADirURL string `json:"caDirURL,omitempty"`
// Restrict certificate issuance to the listed domains.
HostAllowList []string `json:"hostAllowList,omitempty"`
}
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
if o == nil {
return nil
}
out := *o
out.HostAllowList = slices.Clone(o.HostAllowList)
return &out
}
type HTTP2HTTPSPluginOptions struct { type HTTP2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"` LocalAddr string `json:"localAddr,omitempty"`
@@ -124,21 +98,6 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
return &out return &out
} }
type HTTP2HTTPSRedirectPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPSPort int `json:"httpsPort,omitempty"`
}
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type HTTPProxyPluginOptions struct { type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"` HTTPUser string `json:"httpUser,omitempty"`
@@ -163,7 +122,6 @@ type HTTPS2HTTPPluginOptions struct {
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
CrtPath string `json:"crtPath,omitempty"` CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"` KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
} }
func (o *HTTPS2HTTPPluginOptions) Complete() { func (o *HTTPS2HTTPPluginOptions) Complete() {
@@ -177,7 +135,6 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
out := *o out := *o
out.RequestHeaders = o.RequestHeaders.Clone() out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2) out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
out.AutoTLS = o.AutoTLS.Clone()
return &out return &out
} }
@@ -189,7 +146,6 @@ type HTTPS2HTTPSPluginOptions struct {
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
CrtPath string `json:"crtPath,omitempty"` CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"` KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
} }
func (o *HTTPS2HTTPSPluginOptions) Complete() { func (o *HTTPS2HTTPSPluginOptions) Complete() {
@@ -203,7 +159,6 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
out := *o out := *o
out.RequestHeaders = o.RequestHeaders.Clone() out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2) out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
out.AutoTLS = o.AutoTLS.Clone()
return &out return &out
} }
@@ -275,11 +230,10 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
} }
type TLS2RawPluginOptions struct { type TLS2RawPluginOptions struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"` LocalAddr string `json:"localAddr,omitempty"`
CrtPath string `json:"crtPath,omitempty"` CrtPath string `json:"crtPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"` KeyPath string `json:"keyPath,omitempty"`
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
} }
func (o *TLS2RawPluginOptions) Complete() {} func (o *TLS2RawPluginOptions) Complete() {}
@@ -289,7 +243,6 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
return nil return nil
} }
out := *o out := *o
out.AutoTLS = o.AutoTLS.Clone()
return &out return &out
} }

View File

@@ -51,12 +51,6 @@ type ServerConfig struct {
// Vhost requests. If this value is 0, the server will not listen for HTTPS // Vhost requests. If this value is 0, the server will not listen for HTTPS
// requests. // requests.
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"` VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
// VhostHTTPSRedirectPort specifies the port used in the Location header
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
// enabled. Set it when browsers reach the HTTPS vhost through a port
// mapping, so it differs from VhostHTTPSPort. By default, this value is
// VhostHTTPSPort.
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP // TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP // HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
// requests on one single port. If it's not - it will listen on this value for // requests on one single port. If it's not - it will listen on this value for
@@ -124,7 +118,6 @@ func (c *ServerConfig) Complete() error {
} }
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60) c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true)) c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10) c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500) c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
@@ -180,12 +173,6 @@ type ServerTransportConfig struct {
// before terminating the connection. It is not recommended to change this // before terminating the connection. It is not recommended to change this
// value. By default, this value is 90. Set negative value to disable it. // value. By default, this value is 90. Set negative value to disable it.
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"` HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
// ProxyIdleTimeout specifies the maximum time in seconds that a proxied
// user connection can stay open without any traffic in either direction
// before frps closes it. This reclaims connections whose endpoints are
// still open at the TCP level but will never send data again. By default,
// this value is 0, which disables the idle timeout.
ProxyIdleTimeout int64 `json:"proxyIdleTimeout,omitempty"`
// QUIC options. // QUIC options.
QUIC QUICOptions `json:"quic,omitempty"` QUIC QUICOptions `json:"quic,omitempty"`
// TLS specifies TLS settings for the connection from the client. // TLS specifies TLS settings for the connection from the client.

View File

@@ -16,8 +16,6 @@ package validation
import ( import (
"errors" "errors"
"fmt"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
) )
@@ -26,8 +24,6 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
switch v := c.(type) { switch v := c.(type) {
case *v1.HTTP2HTTPSPluginOptions: case *v1.HTTP2HTTPSPluginOptions:
return validateHTTP2HTTPSPluginOptions(v) return validateHTTP2HTTPSPluginOptions(v)
case *v1.HTTP2HTTPSRedirectPluginOptions:
return validateHTTP2HTTPSRedirectPluginOptions(v)
case *v1.HTTPS2HTTPPluginOptions: case *v1.HTTPS2HTTPPluginOptions:
return validateHTTPS2HTTPPluginOptions(v) return validateHTTPS2HTTPPluginOptions(v)
case *v1.HTTPS2HTTPSPluginOptions: case *v1.HTTPS2HTTPSPluginOptions:
@@ -49,17 +45,10 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
return nil return nil
} }
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
return ValidatePort(c.HTTPSPort, "httpsPort")
}
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error { func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
if c.LocalAddr == "" { if c.LocalAddr == "" {
return errors.New("localAddr is required") return errors.New("localAddr is required")
} }
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil return nil
} }
@@ -67,9 +56,6 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
if c.LocalAddr == "" { if c.LocalAddr == "" {
return errors.New("localAddr is required") return errors.New("localAddr is required")
} }
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil return nil
} }
@@ -91,29 +77,5 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
if c.LocalAddr == "" { if c.LocalAddr == "" {
return errors.New("localAddr is required") return errors.New("localAddr is required")
} }
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
return fmt.Errorf("invalid autoTLS options: %w", err)
}
return nil
}
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
if c == nil || !c.Enable {
return nil
}
if crtPath != "" || keyPath != "" {
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
}
if strings.TrimSpace(c.CacheDir) == "" {
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
}
if len(c.HostAllowList) > 0 {
for _, host := range c.HostAllowList {
if strings.TrimSpace(host) == "" {
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
}
}
}
return nil return nil
} }

View File

@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
defer m.mu.Unlock() defer m.mu.Unlock()
total = len(m.info.ProxyStatistics) total = len(m.info.ProxyStatistics)
for name, data := range m.info.ProxyStatistics { for name, data := range m.info.ProxyStatistics {
if !data.LastCloseTime.IsZero() && if m.shouldClearProxyStats(data, continuousOfflineDuration) {
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
delete(m.info.ProxyStatistics, name) delete(m.info.ProxyStatistics, name)
count++ count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String()) log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
return count, total return count, total
} }
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
return !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
}
func (m *serverMetrics) ClearOfflineProxies() (int, int) { func (m *serverMetrics) ClearOfflineProxies() (int, int) {
return m.clearUselessInfo(0) return m.clearUselessInfo(0)
} }
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) NewClient() { func (m *serverMetrics) NewClient() {
m.info.ClientCounts.Inc(1) m.info.ClientCounts.Inc(1)
} }
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
} }
if !proxyStats.LastStartTime.IsZero() { if !proxyStats.LastStartTime.IsZero() {
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05") ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
ps.LastStartAt = proxyStats.LastStartTime.Unix()
} }
if !proxyStats.LastCloseTime.IsZero() { if !proxyStats.LastCloseTime.IsZero() {
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05") ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
} }
return ps return ps
} }
@@ -243,9 +253,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
filterAll := proxyType == "" || proxyType == "all"
for name, proxyStats := range m.info.ProxyStatistics { for name, proxyStats := range m.info.ProxyStatistics {
if !filterAll && proxyStats.ProxyType != proxyType { if proxyStats.ProxyType != proxyType {
continue continue
} }
res = append(res, toProxyStats(name, proxyStats)) res = append(res, toProxyStats(name, proxyStats))
@@ -258,13 +267,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
defer m.mu.Unlock() defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName] proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok { if ok && proxyStats.ProxyType == proxyType {
// filterAll allows the "all proxies" API to look up a proxy by name without res = toProxyStats(proxyName, proxyStats)
// knowing its type (proxyType == "" or "all").
filterAll := proxyType == "" || proxyType == "all"
if filterAll || proxyStats.ProxyType == proxyType {
res = toProxyStats(proxyName, proxyStats)
}
} }
return return
} }

View File

@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
clk.SetTime(closedAt) clk.SetTime(closedAt)
metrics.CloseProxy("proxy", "tcp") metrics.CloseProxy("proxy", "tcp")
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime) require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
stats := metrics.GetProxyByName("proxy")
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
require.Equal(start.Unix(), stats.LastStartAt)
require.Equal(closedAt.Unix(), stats.LastCloseAt)
} }
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) { func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
require.Empty(metrics.info.ProxyStatistics) require.Empty(metrics.info.ProxyStatistics)
} }
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
cleared, total := metrics.ClearOfflineProxies()
require.Equal(1, cleared)
require.Equal(2, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
}
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
Name: "restarted",
LastStartTime: start.Add(30 * time.Second),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
Name: "same-time",
LastStartTime: start,
LastCloseTime: start,
}
cleared, total := metrics.PruneOfflineProxies()
require.Equal(1, cleared)
require.Equal(4, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
require.True(metrics.hasProxyStatistics("restarted"))
require.True(metrics.hasProxyStatistics("same-time"))
cleared, total = metrics.PruneOfflineProxies()
require.Equal(0, cleared)
require.Equal(3, total)
}
func TestServerMetricsRunUsesClockTicker(t *testing.T) { func TestServerMetricsRunUsesClockTicker(t *testing.T) {
require := require.New(t) require := require.New(t)

View File

@@ -41,6 +41,8 @@ type ProxyStats struct {
TodayTrafficOut int64 TodayTrafficOut int64
LastStartTime string LastStartTime string
LastCloseTime string LastCloseTime string
LastStartAt int64
LastCloseAt int64
CurConns int64 CurConns int64
} }
@@ -85,4 +87,5 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int) ClearOfflineProxies() (int, int)
PruneOfflineProxies() (int, int)
} }

View File

@@ -124,7 +124,6 @@ type NewProxy struct {
Headers map[string]string `json:"headers,omitempty"` Headers map[string]string `json:"headers,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"` ResponseHeaders map[string]string `json:"response_headers,omitempty"`
RouteByHTTPUser string `json:"route_by_http_user,omitempty"` RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
HTTPRedirect bool `json:"http_redirect,omitempty"`
// stcp, sudp, xtcp // stcp, sudp, xtcp
Sk string `json:"sk,omitempty"` Sk string `json:"sk,omitempty"`

View File

@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
// Make hole-punching decisions based on the NAT information of the client and visitor. // Make hole-punching decisions based on the NAT information of the client and visitor.
vResp, cResp, err := c.analysis(session) vResp, cResp, err := c.analysis(session)
if err != nil { if err != nil {
log.Debugf("sid [%s] analysis error: %v", sid, err) log.Debugf("sid [%s] analysis error: %v", err)
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error()) vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error()) cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
} }
@@ -385,6 +385,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if !isLast { if !isLast {
return nil return nil
} }
ports := make([]msg.PortsRange, 0, 1)
_, portStr, err := net.SplitHostPort(addr) _, portStr, err := net.SplitHostPort(addr)
if err != nil { if err != nil {
return nil return nil
@@ -393,8 +394,9 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if err != nil { if err != nil {
return nil return nil
} }
return []msg.PortsRange{{ ports = append(ports, msg.PortsRange{
From: max(port-difference-5, port-maxNumber, 1), From: max(port-difference-5, port-maxNumber, 1),
To: min(port+difference+5, port+maxNumber, 65535), To: min(port+difference+5, port+maxNumber, 65535),
}} })
return ports
} }

View File

@@ -1,212 +0,0 @@
// Copyright 2026 The LoliaTeam Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/log"
)
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
if auto == nil || !auto.Enable {
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
}
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
}
hostSet := make(map[string]struct{})
hosts := make([]string, 0, len(auto.HostAllowList))
addHost := func(host string) {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return
}
if strings.Contains(host, "*") {
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
return
}
if _, ok := hostSet[host]; ok {
return
}
hostSet[host] = struct{}{}
hosts = append(hosts, host)
}
for _, host := range auto.HostAllowList {
addHost(host)
}
if len(hosts) == 0 {
for _, host := range fallbackHosts {
addHost(host)
}
}
if len(hosts) == 0 {
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
}
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: strings.TrimSpace(auto.Email),
HostPolicy: autocert.HostWhitelist(hosts...),
}
caDirURL := strings.TrimSpace(auto.CADirURL)
if caDirURL != "" {
manager.Client = &acme.Client{DirectoryURL: caDirURL}
} else {
caDirURL = autocert.DefaultACMEDirectory
}
managedHosts := make(map[string]struct{}, len(hosts))
for _, host := range hosts {
managedHosts[host] = struct{}{}
}
var warmupInProgress sync.Map
var warmupMissLogged sync.Map
manager.Cache = &autoTLSCache{
inner: autocert.DirCache(auto.CacheDir),
managedHosts: managedHosts,
pluginName: pluginName,
caDirURL: caDirURL,
warmupInProgress: &warmupInProgress,
warmupMissLogged: &warmupMissLogged,
}
cfg := manager.TLSConfig()
log.Infof("[autoTLS][%s] 已启用 autoTLS管理域名=%v缓存目录=%s", pluginName, hosts, auto.CacheDir)
var readySeen sync.Map
handleCertReady := func(host string, cert *tls.Certificate) {
var (
notAfter time.Time
hasExpiry bool
)
if t, ok := getCertificateNotAfter(cert); ok {
notAfter = t
hasExpiry = true
}
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
if hasExpiry {
if !readyLogged {
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
}
} else if !readyLogged {
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
}
}
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
if host == "" {
host = "<空SNI>"
}
cert, err := manager.GetCertificate(hello)
if err != nil {
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
return nil, err
}
handleCertReady(host, cert)
return cert, nil
}
// Warm up certificates in background after startup.
for _, host := range hosts {
h := host
go func() {
// Leave time for listener setup and route registration.
time.Sleep(1 * time.Second)
warmupMissLogged.Delete(h)
warmupInProgress.Store(h, struct{}{})
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
warmupInProgress.Delete(h)
if err != nil {
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
return
}
handleCertReady(h, cert)
}()
}
return cfg, nil
}
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
if cert == nil {
return time.Time{}, false
}
if cert.Leaf != nil {
return cert.Leaf.NotAfter, true
}
if len(cert.Certificate) == 0 {
return time.Time{}, false
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return time.Time{}, false
}
return leaf.NotAfter, true
}
type autoTLSCache struct {
inner autocert.Cache
managedHosts map[string]struct{}
pluginName string
caDirURL string
warmupInProgress *sync.Map
warmupMissLogged *sync.Map
}
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
data, err := c.inner.Get(ctx, key)
if err != autocert.ErrCacheMiss {
return data, err
}
host := strings.TrimSuffix(key, "+rsa")
if _, ok := c.managedHosts[host]; !ok {
return data, err
}
if _, warming := c.warmupInProgress.Load(host); !warming {
return data, err
}
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-01caDirURL=%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)
}

View File

@@ -1,111 +0,0 @@
// Copyright 2026 The LoliaTeam Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
}
type HTTP2HTTPSRedirectPlugin struct {
opts *v1.HTTP2HTTPSRedirectPluginOptions
l *Listener
s *http.Server
}
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPSRedirectPlugin{
opts: opts,
l: listener,
}
p.s = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// 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()
}

View File

@@ -18,7 +18,6 @@ package client
import ( import (
"context" "context"
"crypto/tls"
stdlog "log" stdlog "log"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@@ -81,27 +80,11 @@ func newHTTPSBridgePluginServer(
enableHTTP2 *bool, enableHTTP2 *bool,
useSourceRemoteAddr bool, useSourceRemoteAddr bool,
) (*httpBridgePlugin, error) { ) (*httpBridgePlugin, error) {
listener := NewProxyListener()
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2) server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
}
// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
func newHTTPSBridgePluginServerWithTLSConfig(
handler http.Handler,
tlsConfig *tls.Config,
enableHTTP2 *bool,
useSourceRemoteAddr bool,
) *httpBridgePlugin {
server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
}
func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
listener := NewProxyListener()
p := &httpBridgePlugin{ p := &httpBridgePlugin{
l: listener, l: listener,
s: server, s: server,
@@ -110,7 +93,7 @@ func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool
go func() { go func() {
_ = p.s.ServeTLS(listener, "", "") _ = p.s.ServeTLS(listener, "", "")
}() }()
return p return p, nil
} }
func newHTTPBridgeReverseProxy( func newHTTPBridgeReverseProxy(

View File

@@ -17,7 +17,6 @@
package client package client
import ( import (
"fmt"
"net/http/httputil" "net/http/httputil"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -33,7 +32,7 @@ type HTTPS2HTTPPlugin struct {
*httpBridgePlugin *httpBridgePlugin
} }
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPPluginOptions) opts := options.(*v1.HTTPS2HTTPPluginOptions)
p := &HTTPS2HTTPPlugin{ p := &HTTPS2HTTPPlugin{
@@ -50,15 +49,6 @@ func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions
nil, nil,
) )
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, fmt.Errorf("build autoTLS config error: %v", err)
}
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
return p, nil
}
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true) server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -18,7 +18,6 @@ package client
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@@ -35,7 +34,7 @@ type HTTPS2HTTPSPlugin struct {
*httpBridgePlugin *httpBridgePlugin
} }
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPSPluginOptions) opts := options.(*v1.HTTPS2HTTPSPluginOptions)
p := &HTTPS2HTTPSPlugin{ p := &HTTPS2HTTPSPlugin{
@@ -56,15 +55,6 @@ func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOption
tr, tr,
) )
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, fmt.Errorf("build autoTLS config error: %v", err)
}
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
return p, nil
}
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true) server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -33,12 +33,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
if err != nil { if err != nil {
return nil, fmt.Errorf("gen TLS config error: %v", err) return nil, fmt.Errorf("gen TLS config error: %v", err)
} }
return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
}
// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
// It is used by features such as autoTLS that supply their own certificate provider.
func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
server := &http.Server{ server := &http.Server{
Handler: withMisdirectedRequestCheck(handler), Handler: withMisdirectedRequestCheck(handler),
ReadHeaderTimeout: 60 * time.Second, ReadHeaderTimeout: 60 * time.Second,
@@ -47,7 +42,7 @@ func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *
if !lo.FromPtr(enableHTTP2) { if !lo.FromPtr(enableHTTP2) {
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
} }
return server return server, nil
} }
func withMisdirectedRequestCheck(handler http.Handler) http.Handler { func withMisdirectedRequestCheck(handler http.Handler) http.Handler {

View File

@@ -30,7 +30,6 @@ import (
type PluginContext struct { type PluginContext struct {
Name string Name string
HostAllowList []string
VnetController *vnet.Controller VnetController *vnet.Controller
} }

View File

@@ -39,25 +39,16 @@ type TLS2RawPlugin struct {
tlsConfig *tls.Config tlsConfig *tls.Config
} }
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.TLS2RawPluginOptions) opts := options.(*v1.TLS2RawPluginOptions)
p := &TLS2RawPlugin{ p := &TLS2RawPlugin{
opts: opts, opts: opts,
} }
var tlsConfig *tls.Config tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
var err error if err != nil {
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable { return nil, err
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, err
}
} else {
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
}
} }
p.tlsConfig = tlsConfig p.tlsConfig = tlsConfig
return p, nil return p, nil
@@ -81,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
return return
} }
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
rawConn.Close()
tlsConn.Close()
return
}
}
libio.Join(tlsConn, rawConn) libio.Join(tlsConn, rawConn)
} }

View File

@@ -17,13 +17,13 @@ package server
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
charmlog "github.com/charmbracelet/log" goliblog "github.com/fatedier/golib/log"
"github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/msg"
frplog "github.com/fatedier/frp/pkg/util/log" frplog "github.com/fatedier/frp/pkg/util/log"
@@ -39,8 +39,8 @@ type testPlugin struct {
var logCaptureMu sync.Mutex var logCaptureMu sync.Mutex
type logCapture struct { type logCapture struct {
mu sync.Mutex bytes.Buffer
buf bytes.Buffer levels []goliblog.Level
} }
func (p testPlugin) Name() string { func (p testPlugin) Name() string {
@@ -55,36 +55,9 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
return p.handler(ctx, op, content) return p.handler(ctx, op, content)
} }
func (w *logCapture) Write(p []byte) (int, error) { func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
w.mu.Lock() w.levels = append(w.levels, level)
defer w.mu.Unlock() return w.Write(p)
return w.buf.Write(p)
}
func (w *logCapture) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
// levels parses the captured JSON log output and returns the level of each entry.
func (w *logCapture) levels() []charmlog.Level {
var levels []charmlog.Level
for line := range strings.SplitSeq(strings.TrimSpace(w.String()), "\n") {
if line == "" {
continue
}
var entry struct {
Level string `json:"level"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
levels = append(levels, lvl)
}
}
return levels
} }
func captureLogOutput(t *testing.T) *logCapture { func captureLogOutput(t *testing.T) *logCapture {
@@ -93,10 +66,11 @@ func captureLogOutput(t *testing.T) *logCapture {
logCaptureMu.Lock() logCaptureMu.Lock()
logOutput := &logCapture{} logOutput := &logCapture{}
oldLogger := frplog.Logger oldLogger := frplog.Logger
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{ frplog.Logger = goliblog.New(
Level: charmlog.DebugLevel, goliblog.WithOutput(logOutput),
Formatter: charmlog.JSONFormatter, goliblog.WithLevel(goliblog.TraceLevel),
}) goliblog.WithCaller(false),
)
t.Cleanup(func() { t.Cleanup(func() {
frplog.Logger = oldLogger frplog.Logger = oldLogger
logCaptureMu.Unlock() logCaptureMu.Unlock()
@@ -289,10 +263,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
op string op string
level charmlog.Level level goliblog.Level
}{ }{
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel}, {name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel}, {name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -314,9 +288,8 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
if want := "send " + tt.op + " request to plugin error"; err.Error() != want { if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
levels := logOutput.levels() if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
if len(levels) != 1 || levels[0] != tt.level { t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
} }
}) })
} }
@@ -352,13 +325,12 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") { if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
t.Fatalf("missing aggregated errors: %v", err) t.Fatalf("missing aggregated errors: %v", err)
} }
levels := logOutput.levels() if len(logOutput.levels) != 2 {
if len(levels) != 2 { t.Fatalf("expected two warning logs, got %v", logOutput.levels)
t.Fatalf("expected two warning logs, got %v", levels)
} }
for _, level := range levels { for _, level := range logOutput.levels {
if level != charmlog.WarnLevel { if level != goliblog.WarnLevel {
t.Fatalf("expected warning log level, got %v", levels) t.Fatalf("expected warning log level, got %v", logOutput.levels)
} }
} }
} }

View File

@@ -1,18 +0,0 @@
package banner
import (
"fmt"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
)
func DisplayBanner() {
fmt.Println(" __ ___ __________ ____ ________ ____")
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
fmt.Println(" ")
log.Infof("Nya! %s 启动中", version.Full())
}

View File

@@ -16,18 +16,13 @@ package log
import ( import (
"bytes" "bytes"
"io"
"os" "os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss" "github.com/fatedier/golib/log"
"github.com/charmbracelet/log"
) )
var ( var (
TraceLevel = log.DebugLevel TraceLevel = log.TraceLevel
DebugLevel = log.DebugLevel DebugLevel = log.DebugLevel
InfoLevel = log.InfoLevel InfoLevel = log.InfoLevel
WarnLevel = log.WarnLevel WarnLevel = log.WarnLevel
@@ -37,157 +32,39 @@ var (
var Logger *log.Logger var Logger *log.Logger
func init() { func init() {
Logger = log.NewWithOptions(os.Stderr, log.Options{ Logger = log.New(
ReportCaller: true, log.WithCaller(true),
ReportTimestamp: true, log.AddCallerSkip(1),
TimeFormat: time.Kitchen, log.WithLevel(log.InfoLevel),
Prefix: "LoliaFRP-CLI", )
CallerOffset: 1,
})
// 设置自定义样式以支持 Trace 级别
styles := log.DefaultStyles()
styles.Levels[TraceLevel] = lipgloss.NewStyle().
SetString("TRACE").
Bold(true).
MaxWidth(5).
Foreground(lipgloss.Color("61"))
Logger.SetStyles(styles)
} }
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) { func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
var output io.Writer options := []log.Option{}
var err error
if logPath == "console" { if logPath == "console" {
output = os.Stdout if !disableLogColor {
} else { options = append(options,
// Use rotating file writer log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
output, err = NewRotateFileWriter(logPath, maxDays) Colorful: true,
if err != nil { }, os.Stdout)),
// Fallback to console if file creation fails )
output = os.Stdout
} }
} else {
writer := log.NewRotateFileWriter(log.RotateFileConfig{
FileName: logPath,
Mode: log.RotateFileModeDaily,
MaxDays: maxDays,
})
writer.Init()
options = append(options, log.WithOutput(writer))
} }
level, err := log.ParseLevel(levelStr) level, err := log.ParseLevel(levelStr)
if err != nil { if err != nil {
level = log.InfoLevel level = log.InfoLevel
} }
options = append(options, log.WithLevel(level))
Logger = log.NewWithOptions(output, log.Options{ Logger = Logger.WithOptions(options...)
ReportCaller: true,
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "LoliaFRP-CLI",
CallerOffset: 1,
Level: level,
})
}
// NewRotateFileWriter creates a rotating file writer
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
w := &RotateFileWriter{
filePath: filePath,
maxDays: maxDays,
lastRotate: time.Now(),
currentDate: time.Now().Format("2006-01-02"),
}
if err := w.openFile(); err != nil {
return nil, err
}
return w, nil
}
// RotateFileWriter implements io.Writer with daily rotation
type RotateFileWriter struct {
filePath string
maxDays int
file *os.File
lastRotate time.Time
currentDate string
}
func (w *RotateFileWriter) openFile() error {
var err error
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
return err
}
func (w *RotateFileWriter) checkRotate() error {
now := time.Now()
currentDate := now.Format("2006-01-02")
if currentDate != w.currentDate {
// Close current file
if w.file != nil {
w.file.Close()
}
// Rename current file with date suffix
oldPath := w.filePath
newPath := w.filePath + "." + w.currentDate
if _, err := os.Stat(oldPath); err == nil {
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
}
// Clean up old log files
w.cleanupOldLogs(now)
// Update current date and open new file
w.currentDate = currentDate
w.lastRotate = now
return w.openFile()
}
return nil
}
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
if w.maxDays <= 0 {
return
}
cutoffDate := now.AddDate(0, 0, -w.maxDays)
// Find and remove old log files
dir := filepath.Dir(w.filePath)
base := filepath.Base(w.filePath)
files, _ := os.ReadDir(dir)
for _, f := range files {
if f.IsDir() {
continue
}
name := f.Name()
// Extract date from filename (base.YYYY-MM-DD)
if dateStr, ok := strings.CutPrefix(name, base+"."); ok {
if len(dateStr) == 10 {
fileDate, err := time.Parse("2006-01-02", dateStr)
if err == nil && fileDate.Before(cutoffDate) {
os.Remove(filepath.Join(dir, name))
}
}
}
}
}
func (w *RotateFileWriter) Write(p []byte) (n int, err error) {
if err := w.checkRotate(); err != nil {
return 0, err
}
return w.file.Write(p)
}
func (w *RotateFileWriter) Close() error {
if w.file != nil {
return w.file.Close()
}
return nil
} }
func Errorf(format string, v ...any) { func Errorf(format string, v ...any) {
@@ -198,10 +75,6 @@ func Warnf(format string, v ...any) {
Logger.Warnf(format, v...) Logger.Warnf(format, v...)
} }
func Info(format string, v ...any) {
Logger.Info(format, v...)
}
func Infof(format string, v ...any) { func Infof(format string, v ...any) {
Logger.Infof(format, v...) Logger.Infof(format, v...)
} }
@@ -211,12 +84,11 @@ func Debugf(format string, v ...any) {
} }
func Tracef(format string, v ...any) { func Tracef(format string, v ...any) {
Logger.Logf(TraceLevel, format, v...) Logger.Tracef(format, v...)
} }
func Logf(level log.Level, offset int, format string, v ...any) { func Logf(level log.Level, offset int, format string, v ...any) {
// charmbracelet/log doesn't support offset, so we ignore it Logger.Logf(level, offset, format, v...)
Logger.Logf(level, format, v...)
} }
type WriteLogger struct { type WriteLogger struct {
@@ -232,8 +104,6 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
} }
func (w *WriteLogger) Write(p []byte) (n int, err error) { func (w *WriteLogger) Write(p []byte) (n int, err error) {
// charmbracelet/log doesn't support offset in Log Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
msg := string(bytes.TrimRight(p, "\n"))
Logger.Log(w.level, msg)
return len(p), nil return len(p), nil
} }

View File

@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
// frames and close the connection on invalid bytes.
conn.PayloadType = websocket.BinaryFrame
return ctx, conn, nil return ctx, conn, nil
} }
} }

View File

@@ -17,15 +17,9 @@ package net
import ( import (
"context" "context"
"net" "net"
"strings"
) )
func SetDefaultDNSAddress(dnsAddress string) { func SetDefaultDNSAddress(dnsAddress string) {
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
if strings.HasPrefix(dnsAddress, "https://") {
SetDefaultDNSOverHTTPS(dnsAddress)
return
}
if _, _, err := net.SplitHostPort(dnsAddress); err != nil { if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
dnsAddress = net.JoinHostPort(dnsAddress, "53") dnsAddress = net.JoinHostPort(dnsAddress, "53")
} }

View File

@@ -1,181 +0,0 @@
// Copyright 2026 The Lolia Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package net
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
)
const (
dohMimeType = "application/dns-message"
dohMaxResponseSize = 65535
dohRequestTimeout = 10 * time.Second
)
// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends
// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint,
// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query".
func SetDefaultDNSOverHTTPS(dohURL string) {
client := &http.Client{
Timeout: dohRequestTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: dohRequestTimeout,
// Use a fresh resolver to look up the DoH server hostname itself,
// avoiding infinite recursion through net.DefaultResolver.
Resolver: &net.Resolver{},
}).DialContext,
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ForceAttemptHTTP2: true,
},
}
net.DefaultResolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
return &dohConn{
ctx: ctx,
client: client,
url: dohURL,
network: network,
}, nil
},
}
}
// dohConn adapts the DNS wire-format messages exchanged by net.Resolver
// into DNS-over-HTTPS requests. Since dohConn does not implement
// net.PacketConn, the resolver always uses stream (TCP-style) framing with a
// 2-byte big-endian length prefix, regardless of the dialed network. The
// resolver writes a query and then reads the response from the same
// connection; the HTTP round trip happens synchronously inside Write.
type dohConn struct {
ctx context.Context
client *http.Client
url string
network string
mu sync.Mutex
deadline time.Time
reqBuf bytes.Buffer // unprocessed request bytes
respBuf bytes.Buffer // response stream with 2-byte length prefixes
}
func (c *dohConn) Write(b []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.reqBuf.Write(b)
for {
data := c.reqBuf.Bytes()
if len(data) < 2 {
break
}
msgLen := int(binary.BigEndian.Uint16(data))
if len(data) < 2+msgLen {
break
}
msg := make([]byte, msgLen)
copy(msg, data[2:2+msgLen])
c.reqBuf.Next(2 + msgLen)
resp, err := c.roundTrip(msg)
if err != nil {
return 0, err
}
var lenBuf [2]byte
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp)))
c.respBuf.Write(lenBuf[:])
c.respBuf.Write(resp)
}
return len(b), nil
}
func (c *dohConn) Read(b []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.respBuf.Len() == 0 {
return 0, io.EOF
}
return c.respBuf.Read(b)
}
func (c *dohConn) roundTrip(query []byte) ([]byte, error) {
ctx := c.ctx
if !c.deadline.IsZero() {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, c.deadline)
defer cancel()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", dohMimeType)
req.Header.Set("Accept", dohMimeType)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1))
if err != nil {
return nil, err
}
if len(body) > dohMaxResponseSize {
return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize)
}
return body, nil
}
func (c *dohConn) Close() error { return nil }
func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} }
func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} }
func (c *dohConn) SetDeadline(t time.Time) error {
c.mu.Lock()
defer c.mu.Unlock()
c.deadline = t
return nil
}
func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) }
type dohAddr struct {
network string
addr string
}
func (a *dohAddr) Network() string { return a.network }
func (a *dohAddr) String() string { return a.addr }

View File

@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
muxer := http.NewServeMux() muxer := http.NewServeMux()
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) { muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
// frames and close the connection on invalid bytes.
c.PayloadType = websocket.BinaryFrame
notifyCh := make(chan struct{}) notifyCh := make(chan struct{})
conn := WrapCloseNotifyConn(c, func(_ error) { conn := WrapCloseNotifyConn(c, func(_ error) {
close(notifyCh) close(notifyCh)

View File

@@ -14,7 +14,7 @@
package version package version
var version = "LoliaFRP-CLI 0.69.3" var version = "0.70.0"
func Full() string { func Full() string {
return version return version

View File

@@ -39,15 +39,11 @@ var ErrNoRouteFound = errors.New("no route found")
type HTTPReverseProxyOptions struct { type HTTPReverseProxyOptions struct {
ResponseHeaderTimeoutS int64 ResponseHeaderTimeoutS int64
// HTTPSRedirector, if set, redirects requests whose host has no HTTP
// route but opted into HTTP to HTTPS redirection.
HTTPSRedirector *HTTPSRedirector
} }
type HTTPReverseProxy struct { type HTTPReverseProxy struct {
proxy http.Handler proxy http.Handler
vhostRouter *Routers vhostRouter *Routers
httpsRedirector *HTTPSRedirector
responseHeaderTimeout time.Duration responseHeaderTimeout time.Duration
} }
@@ -59,7 +55,6 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
rp := &HTTPReverseProxy{ rp := &HTTPReverseProxy{
responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second, responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,
vhostRouter: vhostRouter, vhostRouter: vhostRouter,
httpsRedirector: option.HTTPSRedirector,
} }
proxy := &httputil.ReverseProxy{ proxy := &httputil.ReverseProxy{
// Modify incoming requests by route policies. // Modify incoming requests by route policies.
@@ -286,12 +281,6 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request)
return return
} }
// Hosts without any real HTTP route may still ask for a redirect to
// their HTTPS endpoint; registered HTTP routes always take precedence.
if rc == nil && rp.httpsRedirector != nil && rp.httpsRedirector.Redirect(rw, newreq) {
return
}
if req.Method == http.MethodConnect { if req.Method == http.MethodConnect {
rp.connectHandler(rw, newreq) rp.connectHandler(rw, newreq)
} else { } else {

View File

@@ -1,93 +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 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
}

View File

@@ -1,46 +0,0 @@
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"))
}

View File

@@ -28,70 +28,23 @@ var NotFoundPagePath = ""
const ( const (
NotFound = `<!DOCTYPE html> NotFound = `<!DOCTYPE html>
<html lang="zh-CN"> <html>
<head> <head>
<meta charset="UTF-8"> <title>Not Found</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <style>
<title>404 - 未绑定域名</title> body {
<style> width: 35em;
body { margin: 0 auto;
font-family: -apple-system, sans-serif; font-family: Tahoma, Verdana, Arial, sans-serif;
display: flex; }
align-items: center; </style>
justify-content: center;
min-height: 100vh;
margin: 0;
background: #fff;
color: #333;
}
.container {
max-width: 600px;
padding: 40px 20px;
text-align: center;
}
h1 {
font-size: 32px;
font-weight: 600;
margin-bottom: 20px;
}
p {
line-height: 1.8;
color: #666;
margin: 10px 0;
}
ul {
text-align: left;
margin: 20px auto;
max-width: 400px;
}
li {
margin: 8px 0;
color: #666;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover { text-decoration: underline; }
.footer {
margin-top: 40px;
font-size: 14px;
color: #999;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <h1>The page you requested was not found.</h1>
<h1>域名未绑定</h1> <p>Sorry, the page you are looking for is currently unavailable.<br/>
<p>这个域名还没有绑定到任何隧道哦 (;д;)</p> Please try again later.</p>
<p><strong>可能是这些原因:</strong></p> <p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
<ul> <p><em>Faithfully yours, frp.</em></p>
<li>域名配置不对,或者没有正确解析</li>
<li>隧道可能还没启动,或者已经停止</li>
<li>自定义域名忘记在服务端配置了</li>
</ul>
<div class="footer">由 <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
</div>
</body> </body>
</html> </html>
` `
@@ -116,7 +69,7 @@ func getNotFoundPageContent() []byte {
func NotFoundResponse() *http.Response { func NotFoundResponse() *http.Response {
header := make(http.Header) header := make(http.Header)
header.Set("server", version.Full()) header.Set("server", "frp/"+version.Full())
header.Set("Content-Type", "text/html") header.Set("Content-Type", "text/html")
content := getNotFoundPageContent() content := getNotFoundPageContent()

View File

@@ -112,5 +112,5 @@ func (l *Logger) Debugf(format string, v ...any) {
} }
func (l *Logger) Tracef(format string, v ...any) { func (l *Logger) Tracef(format string, v ...any) {
log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...) log.Logger.Tracef(l.prefixString+format, v...)
} }

View File

@@ -36,13 +36,12 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.Handle("/metrics", promhttp.Handler()) subRouter.Handle("/metrics", promhttp.Handler())
} }
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager) apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
// apis // apis
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET") subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET") subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET") subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET") subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
@@ -50,10 +49,15 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE") subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET") subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET") subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET") v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET") subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET") v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view // view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")

View File

@@ -92,51 +92,6 @@ func (cm *ControlManager) Close() error {
return nil return nil
} }
// CloseAllProxyByName Finds the tunnel name and closes all tunnels on the same connection.
func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
cm.mu.RLock()
var target *Control
for _, ctl := range cm.ctlsByRunID {
ctl.mu.RLock()
_, ok := ctl.proxies[proxyName]
ctl.mu.RUnlock()
if ok {
target = ctl
break
}
}
cm.mu.RUnlock()
if target == nil {
return fmt.Errorf("no proxy found with name [%s]", proxyName)
}
return target.Close()
}
// KickByProxyName finds the Control that manages the given proxy (tunnel) name and closes
// Bug: The client does not display the kickout message.
func (cm *ControlManager) KickByProxyName(proxyName string) error {
cm.mu.RLock()
var target *Control
for _, ctl := range cm.ctlsByRunID {
ctl.mu.RLock()
_, ok := ctl.proxies[proxyName]
ctl.mu.RUnlock()
if ok {
target = ctl
break
}
}
cm.mu.RUnlock()
if target == nil {
return fmt.Errorf("no proxy found with name [%s]", proxyName)
}
xl := target.xl
xl.Infof("kick client with proxy [%s] by server administrator request", proxyName)
return target.Close()
}
// SessionContext encapsulates the input parameters for creating a new Control. // SessionContext encapsulates the input parameters for creating a new Control.
type SessionContext struct { type SessionContext struct {
// all resource managers and controllers // all resource managers and controllers

View File

@@ -50,9 +50,6 @@ type ResourceController struct {
// For HTTP proxies, forwarding HTTP requests // For HTTP proxies, forwarding HTTP requests
HTTPReverseProxy *vhost.HTTPReverseProxy HTTPReverseProxy *vhost.HTTPReverseProxy
// Domains of HTTPS proxies that requested automatic HTTP to HTTPS redirection
HTTPSRedirector *vhost.HTTPSRedirector
// For HTTPS proxies, route requests to different clients by hostname and other information // For HTTPS proxies, route requests to different clients by hostname and other information
VhostHTTPSMuxer *vhost.HTTPSMuxer VhostHTTPSMuxer *vhost.HTTPSMuxer

View File

@@ -38,35 +38,32 @@ type Controller struct {
serverCfg *v1.ServerConfig serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry clientRegistry *registry.ClientRegistry
pxyManager ProxyManager pxyManager ProxyManager
ctlManager ControlManager
} }
type ProxyManager interface { type ProxyManager interface {
GetByName(name string) (proxy.Proxy, bool) GetByName(name string) (proxy.Proxy, bool)
} }
type ControlManager interface {
CloseAllProxyByName(proxyName string) error
}
func NewController( func NewController(
serverCfg *v1.ServerConfig, serverCfg *v1.ServerConfig,
clientRegistry *registry.ClientRegistry, clientRegistry *registry.ClientRegistry,
pxyManager ProxyManager, pxyManager ProxyManager,
ctlManager ControlManager,
) *Controller { ) *Controller {
return &Controller{ return &Controller{
serverCfg: serverCfg, serverCfg: serverCfg,
clientRegistry: clientRegistry, clientRegistry: clientRegistry,
pxyManager: pxyManager, pxyManager: pxyManager,
ctlManager: ctlManager,
} }
} }
// /api/serverinfo // /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) { func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
return c.buildServerInfoResp(), nil
}
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
serverStats := mem.StatsCollector.GetServer() serverStats := mem.StatsCollector.GetServer()
svrResp := model.ServerInfoResp{ return model.ServerInfoResp{
Version: version.Full(), Version: version.Full(),
BindPort: c.serverCfg.BindPort, BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort, VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -87,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts, ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts, ProxyTypeCounts: serverStats.ProxyTypeCounts,
} }
return svrResp, nil
} }
// /api/clients // /api/clients
@@ -225,24 +220,6 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return proxyInfo, nil return proxyInfo, nil
} }
// POST /api/proxy/:name/close
func (c *Controller) APICloseProxyByName(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name required")
}
if c.ctlManager == nil {
return nil, fmt.Errorf("control manager unavailable")
}
if err := c.ctlManager.CloseAllProxyByName(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
return httppkg.GeneralResponse{Code: 200, Msg: "ok"}, nil
}
// DELETE /api/proxies?status=offline // DELETE /api/proxies?status=offline
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) { func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
status := ctx.Query("status") status := ctx.Query("status")

View File

@@ -17,22 +17,31 @@ package http
import ( import (
"cmp" "cmp"
"fmt" "fmt"
"maps"
"math" "math"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem" "github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model" "github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/registry"
) )
const ( const (
defaultV2Page = 1 defaultV2Page = 1
defaultV2PageSize = 50 defaultV2PageSize = 50
maxV2PageSize = 200 maxV2PageSize = 200
v2SystemPruneTypeOfflineProxies = "offline_proxies"
v2ProxyTrafficDefaultDays = 7
v2ProxyTrafficUnit = "bytes"
v2ProxyTrafficGranularity = "day"
) )
var apiV2ProxyTypes = []string{ var apiV2ProxyTypes = []string{
@@ -46,6 +55,55 @@ var apiV2ProxyTypes = []string{
string(v1.ProxyTypeSUDP), string(v1.ProxyTypeSUDP),
} }
// /api/v2/system/info
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
info := c.buildServerInfoResp()
proxyTypeCounts := info.ProxyTypeCounts
if proxyTypeCounts == nil {
proxyTypeCounts = map[string]int64{}
}
return model.V2SystemInfoResp{
Version: info.Version,
Config: model.V2SystemInfoConfigResp{
BindPort: info.BindPort,
VhostHTTPPort: info.VhostHTTPPort,
VhostHTTPSPort: info.VhostHTTPSPort,
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
KCPBindPort: info.KCPBindPort,
QUICBindPort: info.QUICBindPort,
SubdomainHost: info.SubdomainHost,
MaxPoolCount: info.MaxPoolCount,
MaxPortsPerClient: info.MaxPortsPerClient,
HeartbeatTimeout: info.HeartBeatTimeout,
AllowPortsStr: info.AllowPortsStr,
TLSForce: info.TLSForce,
},
Status: model.V2SystemInfoStatusResp{
TotalTrafficIn: info.TotalTrafficIn,
TotalTrafficOut: info.TotalTrafficOut,
CurConns: info.CurConns,
ClientCounts: info.ClientCounts,
ProxyTypeCounts: proxyTypeCounts,
},
}, nil
}
// /api/v2/system/prune
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
if err != nil {
return nil, err
}
cleared, total := mem.StatsCollector.PruneOfflineProxies()
return model.V2SystemPruneResp{
Type: pruneType,
Cleared: cleared,
Total: total,
}, nil
}
// /api/v2/users // /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx) page, pageSize, err := parseV2PageParams(ctx)
@@ -137,7 +195,26 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
// /api/v2/clients/{key} // /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
return c.APIClientDetail(ctx) key, err := decodeV2PathParam(ctx, "key", "client key")
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
resp := buildClientInfoResp(info)
status := c.buildV2ClientStatus(info)
return model.V2ClientDetailResp{
ClientInfoResp: resp,
Status: status,
}, nil
} }
// /api/v2/proxies // /api/v2/proxies
@@ -179,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
} }
slices.SortFunc(items, func(a, b model.V2ProxyResp) int { slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
if v := cmp.Compare(a.Type, b.Type); v != 0 { if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
return v return v
} }
return cmp.Compare(a.Name, b.Name) return cmp.Compare(a.Name, b.Name)
@@ -190,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
// /api/v2/proxies/{name} // /api/v2/proxies/{name}
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name") name, err := decodeV2PathParam(ctx, "name", "proxy name")
if name == "" { if err != nil {
return nil, fmt.Errorf("missing proxy name") return nil, err
} }
ps := mem.StatsCollector.GetProxyByName(name) ps := mem.StatsCollector.GetProxyByName(name)
@@ -202,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
return c.buildV2ProxyResp(ps), nil return c.buildV2ProxyResp(ps), nil
} }
// /api/v2/proxies/{name}/traffic
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
}
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
raw := ctx.Param(key)
if raw == "" {
return "", fmt.Errorf("missing %s", label)
}
decoded, err := url.PathUnescape(raw)
if err != nil {
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
}
return decoded, nil
}
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp { func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
item, ok := items[user] item, ok := items[user]
if !ok { if !ok {
@@ -261,6 +365,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp") return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
} }
func parseV2SystemPruneType(raw string) (string, error) {
pruneType := strings.ToLower(raw)
switch pruneType {
case "":
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
case v2SystemPruneTypeOfflineProxies:
return pruneType, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
}
}
func matchV2StatusFilter(online bool, filter string) bool { func matchV2StatusFilter(online bool, filter string) bool {
switch filter { switch filter {
case "", "all": case "", "all":
@@ -320,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
values := []string{ values := []string{
item.Name, item.Name,
item.Type, item.Spec.Type,
item.User, item.User,
item.ClientID, item.ClientID,
item.Status.State, item.Status.State,
} }
switch spec := item.Spec.(type) { switch item.Spec.Type {
case *model.TCPOutConf: case string(v1.ProxyTypeTCP):
values = append(values, strconv.Itoa(spec.RemotePort)) if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
case *model.UDPOutConf: values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
values = append(values, strconv.Itoa(spec.RemotePort)) }
case *model.HTTPOutConf: case string(v1.ProxyTypeUDP):
values = append(values, spec.CustomDomains...) if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
values = append(values, spec.SubDomain) values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
case *model.HTTPSOutConf: }
values = append(values, spec.CustomDomains...) case string(v1.ProxyTypeHTTP):
values = append(values, spec.SubDomain) if item.Spec.HTTP != nil {
case *model.TCPMuxOutConf: values = append(values, item.Spec.HTTP.CustomDomains...)
values = append(values, spec.CustomDomains...) values = append(values, item.Spec.HTTP.Subdomain)
values = append(values, spec.SubDomain) }
case string(v1.ProxyTypeHTTPS):
if item.Spec.HTTPS != nil {
values = append(values, item.Spec.HTTPS.CustomDomains...)
values = append(values, item.Spec.HTTPS.Subdomain)
}
case string(v1.ProxyTypeTCPMUX):
if item.Spec.TCPMux != nil {
values = append(values, item.Spec.TCPMux.CustomDomains...)
values = append(values, item.Spec.TCPMux.Subdomain)
}
} }
return containsV2Query(q, values...) return containsV2Query(q, values...)
@@ -366,29 +492,156 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
return items return items
} }
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
history = append(history, model.V2ProxyTrafficPointResp{
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
})
}
return model.V2ProxyTrafficResp{
Name: name,
Unit: v2ProxyTrafficUnit,
Granularity: v2ProxyTrafficGranularity,
History: history,
}
}
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
if todayFirstIndex >= len(values) {
return 0
}
return values[todayFirstIndex]
}
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"}
if info.Online {
status.State = "online"
}
user := info.User
clientID := info.ClientID()
for _, ps := range c.listV2ProxyStats("") {
if ps.User != user || ps.ClientID != clientID {
continue
}
status.CurConns += ps.CurConns
status.ProxyCount++
}
return status
}
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline" state := "offline"
var spec any var cfg v1.ProxyConfigurer
if c.pxyManager != nil { if c.pxyManager != nil {
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
state = "online" state = "online"
spec = getConfFromConfigurer(pxy.GetConfigurer()) cfg = pxy.GetConfigurer()
} }
} }
return model.V2ProxyResp{ return model.V2ProxyResp{
Name: ps.Name, Name: ps.Name,
Type: ps.Type,
User: ps.User, User: ps.User,
ClientID: ps.ClientID, ClientID: ps.ClientID,
Spec: spec, Spec: buildV2ProxySpec(ps.Type, cfg),
Status: model.V2ProxyStatusResp{ Status: model.V2ProxyStatusResp{
State: state, State: state,
TodayTrafficIn: ps.TodayTrafficIn, TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut, TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns, CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime, LastStartAt: ps.LastStartAt,
LastCloseTime: ps.LastCloseTime, LastCloseAt: ps.LastCloseAt,
},
}
}
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
spec := model.V2ProxySpec{Type: proxyType}
switch proxyType {
case string(v1.ProxyTypeTCP):
block := &model.V2TCPProxySpec{}
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.RemotePort = &c.RemotePort
}
spec.TCP = block
case string(v1.ProxyTypeUDP):
block := &model.V2UDPProxySpec{}
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.RemotePort = &c.RemotePort
}
spec.UDP = block
case string(v1.ProxyTypeHTTP):
block := &model.V2HTTPProxySpec{}
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
block.Locations = slices.Clone(c.Locations)
block.HostHeaderRewrite = c.HostHeaderRewrite
}
spec.HTTP = block
case string(v1.ProxyTypeHTTPS):
block := &model.V2HTTPSProxySpec{}
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
}
spec.HTTPS = block
case string(v1.ProxyTypeTCPMUX):
block := &model.V2TCPMuxProxySpec{}
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
block.Multiplexer = c.Multiplexer
block.RouteByHTTPUser = c.RouteByHTTPUser
}
spec.TCPMux = block
case string(v1.ProxyTypeSTCP):
block := &model.V2STCPProxySpec{}
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.STCP = block
case string(v1.ProxyTypeSUDP):
block := &model.V2SUDPProxySpec{}
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.SUDP = block
case string(v1.ProxyTypeXTCP):
block := &model.V2XTCPProxySpec{}
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.XTCP = block
}
return spec
}
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
return model.V2ProxyBaseSpec{
Annotations: maps.Clone(base.Annotations),
Metadatas: maps.Clone(base.Metadatas),
Transport: &model.V2ProxyTransportSpec{
UseEncryption: base.Transport.UseEncryption,
UseCompression: base.Transport.UseCompression,
BandwidthLimit: base.Transport.BandwidthLimit.String(),
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
},
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
Group: base.LoadBalancer.Group,
}, },
} }
} }

View File

@@ -0,0 +1,393 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"strings"
"testing"
configtypes "github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
"github.com/fatedier/frp/server/http/model"
)
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
tests := []struct {
proxyType string
cfg v1.ProxyConfigurer
blockKeys []string
}{
{
proxyType: "tcp",
cfg: &v1.TCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
RemotePort: 6000,
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
},
{
proxyType: "udp",
cfg: &v1.UDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
RemotePort: 7000,
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
},
{
proxyType: "http",
cfg: &v1.HTTPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
Locations: []string{"/api"},
HTTPUser: "secret-http-user",
HTTPPassword: "secret-http-password",
HostHeaderRewrite: "backend.example.com",
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
RouteByHTTPUser: "secret-http-route-user",
},
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
},
{
proxyType: "https",
cfg: &v1.HTTPSProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
},
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
},
{
proxyType: "tcpmux",
cfg: &v1.TCPMuxProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
RouteByHTTPUser: "displayed-mux-user",
Multiplexer: "httpconnect",
},
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
},
{
proxyType: "stcp",
cfg: &v1.STCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
{
proxyType: "sudp",
cfg: &v1.SUDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
{
proxyType: "xtcp",
cfg: &v1.XTCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
}
for _, tt := range tests {
t.Run(tt.proxyType, func(t *testing.T) {
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
raw := mustMarshalJSON(t, spec)
var specObject map[string]json.RawMessage
if err := json.Unmarshal(raw, &specObject); err != nil {
t.Fatalf("unmarshal spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
var gotType string
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
t.Fatalf("unmarshal spec type failed: %v", err)
}
if gotType != tt.proxyType {
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
}
var block map[string]json.RawMessage
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
t.Fatalf("unmarshal active block failed: %v", err)
}
assertRawJSONKeys(t, block, tt.blockKeys...)
assertV2ProxyCommonSpec(t, block)
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
assertNoV2ProxySensitiveFields(t, block)
content := string(raw)
for _, secret := range []string{
"secret-proxy-name",
"secret-group-key",
"secret-local-host",
"secret-plugin-user",
"secret-plugin-password",
"secret-health-path",
"secret-http-user",
"secret-http-password",
"secret-request-header",
"secret-response-header",
"secret-http-route-user",
"secret-mux-http-user",
"secret-mux-http-password",
"secret-stcp-key",
"secret-stcp-user",
"secret-sudp-key",
"secret-sudp-user",
"secret-xtcp-key",
"secret-xtcp-user",
} {
if strings.Contains(content, secret) {
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
}
}
})
}
}
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
t.Helper()
switch proxyType {
case "tcp":
var block model.V2TCPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal tcp block failed: %v", err)
}
if block.RemotePort == nil || *block.RemotePort != 6000 {
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
}
case "udp":
var block model.V2UDPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal udp block failed: %v", err)
}
if block.RemotePort == nil || *block.RemotePort != 7000 {
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
}
case "http":
var block model.V2HTTPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal http block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
block.HostHeaderRewrite != "backend.example.com" {
t.Fatalf("http fields mismatch: %#v", block)
}
case "https":
var block model.V2HTTPSProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal https block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
t.Fatalf("https fields mismatch: %#v", block)
}
case "tcpmux":
var block model.V2TCPMuxProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal tcpmux block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
t.Fatalf("tcpmux fields mismatch: %#v", block)
}
}
}
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
for _, proxyType := range apiV2ProxyTypes {
t.Run(proxyType, func(t *testing.T) {
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
Name: "offline-" + proxyType,
Type: proxyType,
})
if resp.Status.State != "offline" {
t.Fatalf("offline phase mismatch: %#v", resp.Status)
}
var specObject map[string]json.RawMessage
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
t.Fatalf("unmarshal offline spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, proxyType, "type")
assertRawJSONKeysFromMessage(t, specObject[proxyType])
})
}
}
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
RemotePort: 7000,
})
var specObject map[string]json.RawMessage
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
t.Fatalf("unmarshal mismatched spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, "tcp", "type")
assertRawJSONKeysFromMessage(t, specObject["tcp"])
}
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
t.Helper()
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
if err != nil {
t.Fatalf("create bandwidth limit failed: %v", err)
}
enabled := false
return v1.ProxyBaseConfig{
Name: "secret-proxy-name",
Type: proxyType,
Enabled: &enabled,
Annotations: map[string]string{"annotation-key": "annotation-value"},
Metadatas: map[string]string{"metadata-key": "metadata-value"},
Transport: v1.ProxyTransport{
UseEncryption: true,
UseCompression: true,
BandwidthLimit: bandwidthLimit,
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
ProxyProtocolVersion: "v2",
},
LoadBalancer: v1.LoadBalancerConfig{
Group: "public-group",
GroupKey: "secret-group-key",
},
HealthCheck: v1.HealthCheckConfig{
Type: "http",
Path: "secret-health-path",
},
ProxyBackend: v1.ProxyBackend{
LocalIP: "secret-local-host",
LocalPort: 8080,
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginHTTPProxy,
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
Type: v1.PluginHTTPProxy,
HTTPUser: "secret-plugin-user",
HTTPPassword: "secret-plugin-password",
},
},
},
}
}
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
t.Helper()
var annotations map[string]string
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
t.Fatalf("unmarshal annotations failed: %v", err)
}
if annotations["annotation-key"] != "annotation-value" {
t.Fatalf("annotations mismatch: %#v", annotations)
}
var metadatas map[string]string
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
t.Fatalf("unmarshal metadatas failed: %v", err)
}
if metadatas["metadata-key"] != "metadata-value" {
t.Fatalf("metadatas mismatch: %#v", metadatas)
}
assertRawJSONKeysFromMessage(t, block["transport"],
"bandwidthLimit",
"bandwidthLimitMode",
"useCompression",
"useEncryption",
)
var transport model.V2ProxyTransportSpec
if err := json.Unmarshal(block["transport"], &transport); err != nil {
t.Fatalf("unmarshal transport failed: %v", err)
}
if !transport.UseEncryption || !transport.UseCompression ||
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
t.Fatalf("transport mismatch: %#v", transport)
}
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
var loadBalancer model.V2ProxyLoadBalancerSpec
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
t.Fatalf("unmarshal load balancer failed: %v", err)
}
if loadBalancer.Group != "public-group" {
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
}
}
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
t.Helper()
forbidden := map[string]struct{}{
"allowUsers": {},
"enabled": {},
"groupKey": {},
"healthCheck": {},
"httpPassword": {},
"httpUser": {},
"localIP": {},
"localPort": {},
"name": {},
"natTraversal": {},
"plugin": {},
"proxyProtocolVersion": {},
"requestHeaders": {},
"responseHeaders": {},
"secretKey": {},
"type": {},
}
var walk func(any)
walk = func(current any) {
switch current := current.(type) {
case map[string]any:
for key, nested := range current {
if _, ok := forbidden[key]; ok {
t.Fatalf("sensitive field %q leaked in active block", key)
}
walk(nested)
}
case []any:
for _, nested := range current {
walk(nested)
}
}
}
raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal active block failed: %v", err)
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode active block failed: %v", err)
}
walk(decoded)
}

View File

@@ -20,10 +20,13 @@ import (
"math" "math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem" "github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -39,10 +42,16 @@ type v2EnvelopeForTest[T any] struct {
} }
type fakeStatsCollector struct { type fakeStatsCollector struct {
proxies map[string]*mem.ProxyStats server *mem.ServerStats
proxies map[string]*mem.ProxyStats
traffic map[string]*mem.ProxyTrafficInfo
pruneable map[string]bool
} }
func (f *fakeStatsCollector) GetServer() *mem.ServerStats { func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
if f.server != nil {
return f.server
}
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}} return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
} }
@@ -69,13 +78,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
} }
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo { func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
return nil return f.traffic[name]
} }
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) { func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies) return 0, len(f.proxies)
} }
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
total := len(f.proxies)
cleared := 0
for name := range f.pruneable {
if _, ok := f.proxies[name]; ok {
delete(f.proxies, name)
cleared++
}
}
f.pruneable = map[string]bool{}
return cleared, total
}
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
server: &mem.ServerStats{
TotalTrafficIn: 1024,
TotalTrafficOut: 2048,
CurConns: 3,
ClientCounts: 4,
ProxyTypeCounts: map[string]int64{
"tcp": 2,
"http": 1,
},
},
proxies: map[string]*mem.ProxyStats{},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
VhostHTTPSPort: 8443,
TCPMuxHTTPConnectPort: 9000,
KCPBindPort: 7001,
QUICBindPort: 7002,
SubDomainHost: "example.com",
MaxPortsPerClient: 8,
AllowPorts: []types.PortsRange{
{Start: 1000, End: 1002},
{Single: 2000},
},
Transport: v1.ServerTransportConfig{
MaxPoolCount: 5,
HeartbeatTimeout: 90,
TLS: v1.TLSServerConfig{
Force: true,
},
},
}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/system/info")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
"allowPortsStr",
"bindPort",
"heartbeatTimeout",
"kcpBindPort",
"maxPoolCount",
"maxPortsPerClient",
"quicBindPort",
"subdomainHost",
"tcpmuxHTTPConnectPort",
"tlsForce",
"vhostHTTPPort",
"vhostHTTPSPort",
)
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
"clientCounts",
"curConns",
"proxyTypeCount",
"totalTrafficIn",
"totalTrafficOut",
)
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
if systemResp.Data.Version == "" {
t.Fatal("version should be set at top level")
}
if systemResp.Data.Config.BindPort != 7000 ||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
systemResp.Data.Config.KCPBindPort != 7001 ||
systemResp.Data.Config.QUICBindPort != 7002 ||
systemResp.Data.Config.SubdomainHost != "example.com" ||
systemResp.Data.Config.MaxPoolCount != 5 ||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
!systemResp.Data.Config.TLSForce {
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
}
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
systemResp.Data.Status.CurConns != 3 ||
systemResp.Data.Status.ClientCounts != 4 ||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
}
}
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
oldStatsCollector := mem.StatsCollector
collector := &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
"http-offline": {Name: "http-offline", Type: "http"},
"udp-offline": {Name: "udp-offline", Type: "udp"},
"tcp-online": {Name: "tcp-online", Type: "tcp"},
"http-online": {Name: "http-online", Type: "http"},
"udp-online": {Name: "udp-online", Type: "udp"},
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
},
pruneable: map[string]bool{
"tcp-offline": true,
"http-offline": true,
"udp-offline": true,
},
}
mem.StatsCollector = collector
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
}
if _, ok := collector.proxies["tcp-offline"]; ok {
t.Fatal("pruned proxy statistics should be removed")
}
if _, ok := collector.proxies["tcp-online"]; !ok {
t.Fatal("online proxy statistics should remain")
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
}
}
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp = decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) { func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t) controller := newV2TestController(t)
router := newV2TestRouter(controller) router := newV2TestRouter(controller)
@@ -146,10 +352,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code) t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
} }
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp) detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" { if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
t.Fatalf("client detail mismatch: %#v", detailResp.Data) t.Fatalf("client detail mismatch: %#v", detailResp.Data)
} }
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
}
}
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-url": {
Name: "tcp-url",
Type: "tcp",
User: "url",
ClientID: "client/a?b#c",
CurConns: 7,
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedKey := url.PathEscape("url.client/a?b#c")
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
if resp.Code != http.StatusOK {
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
}
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
}
} }
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
@@ -171,28 +416,194 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data) t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
} }
proxyItem := proxyResp.Data.Items[0] proxyItem := proxyResp.Data.Items[0]
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" { if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
t.Fatalf("proxy item mismatch: %#v", proxyItem) t.Fatalf("proxy item mismatch: %#v", proxyItem)
} }
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
var rawListSpec map[string]json.RawMessage
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
t.Fatalf("unmarshal list proxy spec failed: %v", err)
}
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
resp = performRequest(router, "/api/v2/proxies/tcp-alice") resp = performRequest(router, "/api/v2/proxies/tcp-alice")
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
"curConns",
"lastCloseAt",
"lastStartAt",
"phase",
"todayTrafficIn",
"todayTrafficOut",
)
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp) proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" { if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data) t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
} }
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
var rawDetailSpec map[string]json.RawMessage
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
}
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
}
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50") resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp) userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
if userResp.Data.Total != 3 { if userResp.Data.Total != 3 {
t.Fatalf("user total mismatch: %#v", userResp.Data) t.Fatalf("user total mismatch: %#v", userResp.Data)
} }
expectedProxyCounts := map[string]int{
"": 1,
"alice": 2,
"bob": 1,
}
for _, item := range userResp.Data.Items { for _, item := range userResp.Data.Items {
if item.ClientCount != 1 || item.ProxyCount != 1 { if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
t.Fatalf("user counts mismatch: %#v", item) t.Fatalf("user counts mismatch: %#v", item)
} }
} }
} }
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"ssh": {Name: "ssh", Type: "tcp"},
},
traffic: map[string]*mem.ProxyTrafficInfo{
"ssh": {
Name: "ssh",
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
}
if len(trafficResp.Data.History) != 7 {
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
}
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
var prevDate time.Time
for i, point := range trafficResp.Data.History {
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
}
parsedDate, err := time.Parse(time.DateOnly, point.Date)
if err != nil {
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
}
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
}
prevDate = parsedDate
}
}
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
if resp.Code != http.StatusNotFound {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
t.Fatalf("not found envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
name := "folder/ssh?x#y"
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
name: {Name: name, Type: "tcp", User: "encoded"},
},
traffic: map[string]*mem.ProxyTrafficInfo{
name: {
Name: name,
TrafficIn: []int64{1},
TrafficOut: []int64{2},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedName := url.PathEscape(name)
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
if resp.Code != http.StatusOK {
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
}
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
if resp.Code != http.StatusOK {
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
if trafficResp.Data.Name != name {
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
}
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
}
}
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
controller := newV2TestController(t)
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
}
}
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) { func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -202,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
}{ }{
{ {
name: "tcp remote port", name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
RemotePort: 6000, Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}}, }},
q: "6000", q: "6000",
want: true, want: true,
}, },
{ {
name: "udp remote port", name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{ item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
RemotePort: 7000, Type: "udp",
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
}}, }},
q: "7000", q: "7000",
want: true, want: true,
}, },
{ {
name: "remote port does not match colon form", name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
RemotePort: 6000, Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}}, }},
q: ":6000", q: ":6000",
want: false, want: false,
}, },
{ {
name: "http custom domain", name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{ item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}}, Type: "http",
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
}}, }},
q: "app.example.com", q: "app.example.com",
want: true, want: true,
}, },
{ {
name: "https subdomain", name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{SubDomain: "portal"}, Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}}, }},
q: "portal", q: "portal",
want: true, want: true,
}, },
{ {
name: "subdomain does not match expanded host", name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{SubDomain: "portal"}, Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}}, }},
q: "portal.example.com", q: "portal.example.com",
want: false, want: false,
}, },
{ {
name: "tcpmux custom domain", name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{ item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}}, Type: "tcpmux",
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
}}, }},
q: "mux.example.com", q: "mux.example.com",
want: true, want: true,
}, },
{ {
name: "nil spec does not match spec fields", name: "offline shell does not match online spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil}, item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "6000", q: "6000",
want: false, want: false,
}, },
{
name: "offline shell does not contribute zero remote port",
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "0",
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -277,7 +707,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t) controller := newV2TestController(t)
router := newV2TestRouter(controller) router := newV2TestRouter(controller)
resp := performRequest(router, "/api/clients") resp := performRequest(router, "/api/serverinfo")
var serverInfo model.ServerInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
}
if serverInfo.Version == "" {
t.Fatal("legacy serverinfo version should be set")
}
var serverInfoRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
}
if _, ok := serverInfoRaw["data"]; ok {
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
}
if _, ok := serverInfoRaw["config"]; ok {
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
}
resp = performRequest(router, "/api/clients")
var clients []model.ClientInfoResp var clients []model.ClientInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil { if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String()) t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
@@ -298,6 +747,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 { if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope) t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
} }
resp = performRequest(router, "/api/traffic/tcp-alice")
var traffic model.GetProxyTrafficResp
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
}
if traffic.Name != "tcp-alice" ||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
}
var trafficRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
}
if _, ok := trafficRaw["data"]; ok {
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
}
}
func v2TestIntPtr(value int) *int {
return &value
} }
func newV2TestController(t *testing.T) *Controller { func newV2TestController(t *testing.T) *Controller {
@@ -322,6 +793,18 @@ func newV2TestController(t *testing.T) *Controller {
ClientID: "client-a", ClientID: "client-a",
TodayTrafficIn: 30, TodayTrafficIn: 30,
TodayTrafficOut: 40, TodayTrafficOut: 40,
CurConns: 2,
LastStartTime: "07-08 12:30:00",
LastCloseTime: "07-08 12:31:40",
LastStartAt: 1783504200,
LastCloseAt: 1783504300,
},
"http-alice": {
Name: "http-alice",
Type: "http",
User: "alice",
ClientID: "client-a",
CurConns: 3,
}, },
"udp-bob": { "udp-bob": {
Name: "udp-bob", Name: "udp-bob",
@@ -330,6 +813,13 @@ func newV2TestController(t *testing.T) *Controller {
ClientID: "client-b", ClientID: "client-b",
}, },
}, },
traffic: map[string]*mem.ProxyTrafficInfo{
"tcp-alice": {
Name: "tcp-alice",
TrafficIn: []int64{7, 6},
TrafficOut: []int64{70, 60},
},
},
} }
t.Cleanup(func() { t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector mem.StatsCollector = oldStatsCollector
@@ -341,23 +831,34 @@ func newV2TestController(t *testing.T) *Controller {
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1") clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
clientRegistry.MarkOfflineByRunID("run-b") clientRegistry.MarkOfflineByRunID("run-b")
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), nil) return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
} }
func newV2TestRouter(controller *Controller) *mux.Router { func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet) encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet) encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet) router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet) router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
return router return router
} }
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder { func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil) return performRequestWithMethod(handler, http.MethodGet, target)
}
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req) handler.ServeHTTP(resp, req)
return resp return resp
@@ -372,3 +873,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
} }
return out return out
} }
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
t.Helper()
if len(raw) != len(want) {
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
}
for _, key := range want {
if _, ok := raw[key]; !ok {
t.Fatalf("json key %q missing from %v", key, raw)
}
}
}
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
t.Helper()
var out map[string]json.RawMessage
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
}
assertRawJSONKeys(t, out, want...)
}
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
t.Helper()
out, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json failed: %v", err)
}
return out
}

View File

@@ -21,26 +21,159 @@ type V2PageResp[T any] struct {
Items []T `json:"items"` Items []T `json:"items"`
} }
type V2SystemInfoResp struct {
Version string `json:"version"`
Config V2SystemInfoConfigResp `json:"config"`
Status V2SystemInfoStatusResp `json:"status"`
}
type V2SystemInfoConfigResp struct {
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr"`
TLSForce bool `json:"tlsForce"`
}
type V2SystemInfoStatusResp struct {
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type V2SystemPruneResp struct {
Type string `json:"type"`
Cleared int `json:"cleared"`
Total int `json:"total"`
}
type V2UserResp struct { type V2UserResp struct {
User string `json:"user"` User string `json:"user"`
ClientCount int `json:"clientCount"` ClientCount int `json:"clientCount"`
ProxyCount int `json:"proxyCount"` ProxyCount int `json:"proxyCount"`
} }
type V2ClientDetailResp struct {
ClientInfoResp
Status V2ClientStatusResp `json:"status"`
}
type V2ClientStatusResp struct {
State string `json:"phase"`
CurConns int64 `json:"curConns"`
ProxyCount int64 `json:"proxyCount"`
}
type V2ProxyResp struct { type V2ProxyResp struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"`
User string `json:"user"` User string `json:"user"`
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
Spec any `json:"spec"` Spec V2ProxySpec `json:"spec"`
Status V2ProxyStatusResp `json:"status"` Status V2ProxyStatusResp `json:"status"`
} }
type V2ProxySpec struct {
Type string `json:"type"`
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
UDP *V2UDPProxySpec `json:"udp,omitempty"`
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
}
type V2ProxyBaseSpec struct {
Annotations map[string]string `json:"annotations,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
}
type V2ProxyTransportSpec struct {
UseEncryption bool `json:"useEncryption"`
UseCompression bool `json:"useCompression"`
BandwidthLimit string `json:"bandwidthLimit"`
BandwidthLimitMode string `json:"bandwidthLimitMode"`
}
type V2ProxyLoadBalancerSpec struct {
Group string `json:"group"`
}
type V2TCPProxySpec struct {
V2ProxyBaseSpec
RemotePort *int `json:"remotePort,omitempty"`
}
type V2UDPProxySpec struct {
V2ProxyBaseSpec
RemotePort *int `json:"remotePort,omitempty"`
}
type V2HTTPProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"`
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
}
type V2HTTPSProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
}
type V2TCPMuxProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Multiplexer string `json:"multiplexer,omitempty"`
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
}
type V2STCPProxySpec struct {
V2ProxyBaseSpec
}
type V2SUDPProxySpec struct {
V2ProxyBaseSpec
}
type V2XTCPProxySpec struct {
V2ProxyBaseSpec
}
type V2ProxyStatusResp struct { type V2ProxyStatusResp struct {
State string `json:"phase"` State string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"` TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"` CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"` LastStartAt int64 `json:"lastStartAt,omitempty"`
LastCloseTime string `json:"lastCloseTime"` LastCloseAt int64 `json:"lastCloseAt,omitempty"`
}
type V2ProxyTrafficResp struct {
Name string `json:"name"`
Unit string `json:"unit"`
Granularity string `json:"granularity"`
History []V2ProxyTrafficPointResp `json:"history"`
}
type V2ProxyTrafficPointResp struct {
Date string `json:"date"`
TrafficIn int64 `json:"trafficIn"`
TrafficOut int64 `json:"trafficOut"`
} }

View File

@@ -144,26 +144,18 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
}) })
} }
name := pxy.GetName()
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
rwc = wrapCountingReadWriteCloser(rwc, func(bytes int64) {
metrics.Server.AddTrafficOut(name, proxyType, bytes)
}, func(bytes int64) {
metrics.Server.AddTrafficIn(name, proxyType, bytes)
})
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn) workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
workConn = netpkg.WrapCloseNotifyConn(workConn, func(error) { workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)
pxy.updateStatsAfterClosedConn() metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
})
metrics.Server.OpenConnection(name, proxyType)
return return
} }
func (pxy *HTTPProxy) updateStatsAfterClosedConn() { func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {
name := pxy.GetName() name := pxy.GetName()
proxyType := pxy.GetConfigurer().GetBaseConfig().Type proxyType := pxy.GetConfigurer().GetBaseConfig().Type
metrics.Server.CloseConnection(name, proxyType) metrics.Server.CloseConnection(name, proxyType)
metrics.Server.AddTrafficIn(name, proxyType, totalWrite)
metrics.Server.AddTrafficOut(name, proxyType, totalRead)
} }
func (pxy *HTTPProxy) Close() { func (pxy *HTTPProxy) Close() {

View File

@@ -31,9 +31,6 @@ func init() {
type HTTPSProxy struct { type HTTPSProxy struct {
*BaseProxy *BaseProxy
cfg *v1.HTTPSProxyConfig cfg *v1.HTTPSProxyConfig
// domains registered for HTTP to HTTPS redirection, removed on Close
redirectDomains []string
} }
func NewHTTPSProxy(baseProxy *BaseProxy) Proxy { func NewHTTPSProxy(baseProxy *BaseProxy) Proxy {
@@ -69,28 +66,12 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group) xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
} }
if pxy.cfg.HTTPRedirect {
if pxy.rc.HTTPSRedirector != nil {
for _, domain := range domains {
pxy.rc.HTTPSRedirector.Add(domain)
}
pxy.redirectDomains = domains
xl.Infof("https proxy enabled http redirect for hosts %v", domains)
} else {
xl.Warnf("httpRedirect is enabled but frps has no vhostHTTPPort or vhostHTTPSPort, ignored")
}
}
pxy.startCommonTCPListenersHandler() pxy.startCommonTCPListenersHandler()
remoteAddr = strings.Join(addrs, ",") remoteAddr = strings.Join(addrs, ",")
return return
} }
func (pxy *HTTPSProxy) Close() { func (pxy *HTTPSProxy) Close() {
for _, domain := range pxy.redirectDomains {
pxy.rc.HTTPSRedirector.Remove(domain)
}
pxy.redirectDomains = nil
pxy.BaseProxy.Close() pxy.BaseProxy.Close()
} }

View File

@@ -1,47 +0,0 @@
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)
}

View File

@@ -1,52 +0,0 @@
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")
}

View File

@@ -317,38 +317,11 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
name := pxy.GetName() name := pxy.GetName()
proxyType := cfg.Type proxyType := cfg.Type
var watcher *idleWatcher
touch := func() {
if watcher != nil {
watcher.touch()
}
}
local = wrapCountingReadWriteCloser(local, nil, func(bytes int64) {
touch()
metrics.Server.AddTrafficIn(name, proxyType, bytes)
})
userConn = netpkg.WrapReadWriteCloserToConn(
wrapCountingReadWriteCloser(userConn, nil, func(bytes int64) {
touch()
metrics.Server.AddTrafficOut(name, proxyType, bytes)
}),
userConn,
)
if idleTimeout := time.Duration(pxy.serverCfg.Transport.ProxyIdleTimeout) * time.Second; idleTimeout > 0 {
wrappedLocal, wrappedUserConn := local, userConn
watcher = startIdleWatcher(idleTimeout, func() {
xl.Infof("close user connection [%s] of proxy [%s]: no traffic in either direction for %s",
wrappedUserConn.RemoteAddr().String(), name, idleTimeout)
_ = wrappedUserConn.Close()
_ = wrappedLocal.Close()
})
defer watcher.stop()
}
metrics.Server.OpenConnection(name, proxyType) metrics.Server.OpenConnection(name, proxyType)
// Traffic is counted incrementally via the counting wrappers above, so the inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
// byte totals returned by joinUserConnection are intentionally discarded here.
_, _, _ = pxy.joinUserConnection(local, userConn, proxyType, xl)
metrics.Server.CloseConnection(name, proxyType) metrics.Server.CloseConnection(name, proxyType)
metrics.Server.AddTrafficIn(name, proxyType, inCount)
metrics.Server.AddTrafficOut(name, proxyType, outCount)
xl.Debugf("join connections closed") xl.Debugf("join connections closed")
} }

View File

@@ -1,36 +0,0 @@
package proxy
import "io"
type countingReadWriteCloser struct {
io.ReadWriteCloser
onRead func(int64)
onWrite func(int64)
}
func wrapCountingReadWriteCloser(rwc io.ReadWriteCloser, onRead, onWrite func(int64)) io.ReadWriteCloser {
if onRead == nil && onWrite == nil {
return rwc
}
return &countingReadWriteCloser{
ReadWriteCloser: rwc,
onRead: onRead,
onWrite: onWrite,
}
}
func (c *countingReadWriteCloser) Read(p []byte) (n int, err error) {
n, err = c.ReadWriteCloser.Read(p)
if n > 0 && c.onRead != nil {
c.onRead(int64(n))
}
return
}
func (c *countingReadWriteCloser) Write(p []byte) (n int, err error) {
n, err = c.ReadWriteCloser.Write(p)
if n > 0 && c.onWrite != nil {
c.onWrite(int64(n))
}
return
}

View File

@@ -291,14 +291,8 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
// Create http vhost muxer. // Create http vhost muxer.
if cfg.VhostHTTPPort > 0 { if cfg.VhostHTTPPort > 0 {
var redirector *vhost.HTTPSRedirector
if cfg.VhostHTTPSPort > 0 {
redirector = vhost.NewHTTPSRedirector(cfg.VhostHTTPSRedirectPort)
svr.rc.HTTPSRedirector = redirector
}
rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{ rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout, ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
HTTPSRedirector: redirector,
}, svr.httpVhostRouter) }, svr.httpVhostRouter)
svr.rc.HTTPReverseProxy = rp svr.rc.HTTPReverseProxy = rp

View File

@@ -1,18 +1,24 @@
package plugin package plugin
import ( import (
"bufio"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io"
"net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/onsi/ginkgo/v2" "github.com/onsi/ginkgo/v2"
pp "github.com/pires/go-proxyproto"
"github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/httpserver"
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
"github.com/fatedier/frp/test/e2e/pkg/cert" "github.com/fatedier/frp/test/e2e/pkg/cert"
"github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/port"
"github.com/fatedier/frp/test/e2e/pkg/request" "github.com/fatedier/frp/test/e2e/pkg/request"
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ExpectResp([]byte("test")). ExpectResp([]byte("test")).
Ensure() Ensure()
}) })
ginkgo.It("tls2raw with proxy protocol v2", func() {
generator := &cert.SelfSignedCertGenerator{}
artifacts, err := generator.Generate("example.com")
framework.ExpectNoError(err)
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
serverConf := consts.DefaultServerConfig
vhostHTTPSPort := f.AllocPort()
serverConf += fmt.Sprintf(`
vhostHTTPSPort = %d
`, vhostHTTPSPort)
localPort := f.AllocPort()
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
[[proxies]]
name = "tls2raw-proxy-protocol-test"
type = "https"
customDomains = ["example.com"]
transport.proxyProtocolVersion = "v2"
[proxies.plugin]
type = "tls2raw"
localAddr = "127.0.0.1:%d"
crtPath = "%s"
keyPath = "%s"
`, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf})
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
streamserver.WithCustomHandler(func(c net.Conn) {
defer c.Close()
writeResp := func(body string) {
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
}
rd := bufio.NewReader(c)
ppHeader, err := pp.Read(rd)
if err != nil {
log.Errorf("read proxy protocol error: %v", err)
writeResp("missing proxy protocol")
return
}
if ppHeader.Version != 2 {
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
writeResp("unexpected proxy protocol version")
return
}
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
if !ok || srcAddr.IP.String() != "127.0.0.1" {
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
writeResp("unexpected proxy protocol source address")
return
}
req, err := http.ReadRequest(rd)
if err != nil {
log.Errorf("read http request after proxy protocol error: %v", err)
writeResp("missing http request")
return
}
_, _ = io.Copy(io.Discard, req.Body)
_ = req.Body.Close()
writeResp("test")
}))
f.RunServer("", localServer)
framework.NewRequestExpect(f).
Port(vhostHTTPSPort).
RequestModify(func(r *request.Request) {
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
ServerName: "example.com",
InsecureSkipVerify: true,
})
}).
ExpectResp([]byte("test")).
Ensure()
})
}) })

View File

@@ -15,7 +15,6 @@ declare module 'vue' {
ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']

View File

@@ -24,3 +24,7 @@ export const getClientsV2 = (params: ClientListV2Params = {}) => {
export const getClient = (key: string) => { export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`) return http.get<ClientInfoData>(`../api/clients/${key}`)
} }
export const getClientV2 = (key: string) => {
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
}

View File

@@ -98,6 +98,13 @@ export const http = {
request<T>(url, { ...options, method: 'GET' }), request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) => getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }), 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) => post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, { request<T>(url, {
...options, ...options,

View File

@@ -1,13 +1,23 @@
import { buildQueryString, http } from './http' import { buildQueryString, http } from './http'
import { formatUnixSeconds } from '../utils/format'
import type { V2Page } from './http' import type { V2Page } from './http'
import type { import type {
GetProxyResponse, GetProxyResponse,
ProxyListV2Params, ProxyListV2Params,
ProxyStatsInfo, ProxyStatsInfo,
ProxyV2Info, ProxyV2Info,
ProxyV2Spec,
ProxyV2SpecBlocks,
ProxyV2Type,
TrafficResponse, TrafficResponse,
} from '../types/proxy' } from '../types/proxy'
export interface SystemPruneResponse {
type: 'offline_proxies'
cleared: number
total: number
}
export const getProxiesByType = (type: string) => { export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`) return http.get<GetProxyResponse>(`../api/proxy/${type}`)
} }
@@ -32,32 +42,77 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
} }
} }
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({ const getActiveProxySpec = (
name: proxy.name, spec: ProxyV2Spec,
type: proxy.type, ): ProxyV2SpecBlocks[ProxyV2Type] => {
conf: proxy.spec, switch (spec.type) {
user: proxy.user, case 'tcp':
clientID: proxy.clientID, return spec.tcp
todayTrafficIn: proxy.status.todayTrafficIn, case 'udp':
todayTrafficOut: proxy.status.todayTrafficOut, return spec.udp
curConns: proxy.status.curConns, case 'http':
lastStartTime: proxy.status.lastStartTime, return spec.http
lastCloseTime: proxy.status.lastCloseTime, case 'https':
status: proxy.status.phase, 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,
}
}
export const getProxy = (type: string, name: string) => { export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`) return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
} }
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) => { export const getProxyByName = (name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`) return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
} }
export const getProxyTraffic = (name: string) => { export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`) return http.getV2<TrafficResponse>(
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
)
} }
export const clearOfflineProxies = () => { export const clearOfflineProxies = () => {
return http.delete('../api/proxies?status=offline') return http.postV2<SystemPruneResponse>(
'../api/v2/system/prune?type=offline_proxies',
)
} }

View File

@@ -2,5 +2,5 @@ import { http } from './http'
import type { ServerInfo } from '../types/server' import type { ServerInfo } from '../types/server'
export const getServerInfo = () => { export const getServerInfo = () => {
return http.get<ServerInfo>('../api/serverinfo') return http.getV2<ServerInfo>('../api/v2/system/info')
} }

View File

@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { formatFileSize } from '../utils/format' import { formatFileSize } from '../utils/format'
import { getProxyTraffic } from '../api/proxy' import { getProxyTraffic } from '../api/proxy'
import type { TrafficResponse } from '../types/proxy'
const props = defineProps<{ const props = defineProps<{
proxyName: string proxyName: string
@@ -71,41 +72,24 @@ const chartData = ref<
>([]) >([])
const maxVal = ref(0) const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => { const formatDateLabel = (date: string) => {
// Ensure we have arrays and reverse them (server returns newest first) const parts = date.split('-')
const inArr = [...(trafficIn || [])].reverse() if (parts.length !== 3) return date
const outArr = [...(trafficOut || [])].reverse() return `${Number(parts[1])}-${Number(parts[2])}`
}
// Pad with zeros if less than 7 days const processData = (history: TrafficResponse['history'] = []) => {
while (inArr.length < 7) inArr.unshift(0) const points = history || []
while (outArr.length < 7) outArr.unshift(0) const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
// 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 maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
// Build chart data chartData.value = points.map((item) => ({
chartData.value = dates.map((date, i) => ({ date: formatDateLabel(item.date),
date, in: item.trafficIn,
in: finalIn[i], out: item.trafficOut,
out: finalOut[i], inPercent: (item.trafficIn / maxVal.value) * 100,
inPercent: (finalIn[i] / maxVal.value) * 100, outPercent: (item.trafficOut / maxVal.value) * 100,
outPercent: (finalOut[i] / maxVal.value) * 100,
})) }))
} }
@@ -113,7 +97,7 @@ const fetchData = () => {
loading.value = true loading.value = true
getProxyTraffic(props.proxyName) getProxyTraffic(props.proxyName)
.then((json) => { .then((json) => {
processData(json.trafficIn, json.trafficOut) processData(json.history)
}) })
.catch((err) => { .catch((err) => {
ElMessage({ ElMessage({

View File

@@ -7,11 +7,17 @@ export interface ClientInfoData {
wireProtocol?: string wireProtocol?: string
hostname: string hostname: string
clientIP?: string clientIP?: string
metas?: Record<string, string>
firstConnectedAt: number firstConnectedAt: number
lastConnectedAt: number lastConnectedAt: number
disconnectedAt?: number disconnectedAt?: number
online: boolean online: boolean
status?: ClientStatus
}
export interface ClientStatus {
phase: 'online' | 'offline'
curConns: number
proxyCount: number
} }
export interface ClientListV2Params { export interface ClientListV2Params {

View File

@@ -28,24 +28,102 @@ export interface ProxyListV2Params {
export interface ProxyV2Info { export interface ProxyV2Info {
name: string name: string
type: string
user: string user: string
clientID: string clientID: string
spec: any spec: ProxyV2Spec
status: ProxyV2Status 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 { export interface ProxyV2Status {
phase: string phase: 'online' | 'offline'
todayTrafficIn: number todayTrafficIn: number
todayTrafficOut: number todayTrafficOut: number
curConns: number curConns: number
lastStartTime: string lastStartAt?: number
lastCloseTime: string lastCloseAt?: number
} }
export interface TrafficResponse { export interface TrafficResponse {
name: string name: string
trafficIn: number[] unit: 'bytes'
trafficOut: number[] granularity: 'day'
history: TrafficPoint[]
}
export interface TrafficPoint {
date: string
trafficIn: number
trafficOut: number
} }

View File

@@ -1,5 +1,10 @@
export interface ServerInfo { export interface ServerInfo {
version: string version: string
config: ServerInfoConfig
status: ServerInfoStatus
}
export interface ServerInfoConfig {
bindPort: number bindPort: number
vhostHTTPPort: number vhostHTTPPort: number
vhostHTTPSPort: number vhostHTTPSPort: number
@@ -12,8 +17,9 @@ export interface ServerInfo {
heartbeatTimeout: number heartbeatTimeout: number
allowPortsStr: string allowPortsStr: string
tlsForce: boolean tlsForce: boolean
}
// Stats export interface ServerInfoStatus {
totalTrafficIn: number totalTrafficIn: number
totalTrafficOut: number totalTrafficOut: number
curConns: number curConns: number

View File

@@ -1,5 +1,5 @@
import { formatDistanceToNow } from './format' import { formatDistanceToNow } from './format'
import type { ClientInfoData } from '../types/client' import type { ClientInfoData, ClientStatus } from '../types/client'
export class Client { export class Client {
key: string key: string
@@ -10,11 +10,11 @@ export class Client {
wireProtocol: string wireProtocol: string
hostname: string hostname: string
ip: string ip: string
metas: Map<string, string>
firstConnectedAt: Date firstConnectedAt: Date
lastConnectedAt: Date lastConnectedAt: Date
disconnectedAt?: Date disconnectedAt?: Date
online: boolean online: boolean
status: ClientStatus
constructor(data: ClientInfoData) { constructor(data: ClientInfoData) {
this.key = data.key this.key = data.key
@@ -25,18 +25,17 @@ export class Client {
this.wireProtocol = data.wireProtocol || '' this.wireProtocol = data.wireProtocol || ''
this.hostname = data.hostname this.hostname = data.hostname
this.ip = data.clientIP || '' 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.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000) this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
if (data.disconnectedAt && data.disconnectedAt > 0) { if (data.disconnectedAt && data.disconnectedAt > 0) {
this.disconnectedAt = new Date(data.disconnectedAt * 1000) this.disconnectedAt = new Date(data.disconnectedAt * 1000)
} }
this.online = data.online this.online = data.online
this.status = data.status || {
phase: this.online ? 'online' : 'offline',
curConns: 0,
proxyCount: 0,
}
} }
get displayName(): string { get displayName(): string {
@@ -46,10 +45,6 @@ export class Client {
return this.runID return this.runID
} }
get shortRunId(): string {
return this.runID.substring(0, 8)
}
get wireProtocolLabel(): string { get wireProtocolLabel(): string {
if (!this.wireProtocol) return '' if (!this.wireProtocol) return ''
return `Protocol ${this.wireProtocol}` return `Protocol ${this.wireProtocol}`
@@ -67,28 +62,4 @@ export class Client {
if (!this.disconnectedAt) return '' if (!this.disconnectedAt) return ''
return formatDistanceToNow(this.disconnectedAt) 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)
)
}
} }

View File

@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
return Math.floor(seconds) + ' seconds ago' 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 { export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B' if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'

View File

@@ -55,7 +55,7 @@
<div class="info-section"> <div class="info-section">
<div class="info-item"> <div class="info-item">
<span class="info-label">Connections</span> <span class="info-label">Connections</span>
<span class="info-value">{{ totalConnections }}</span> <span class="info-value">{{ client.status.curConns }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">Run ID</span> <span class="info-label">Run ID</span>
@@ -85,7 +85,7 @@
<div class="proxies-header"> <div class="proxies-header">
<div class="proxies-title"> <div class="proxies-title">
<h2>Proxies</h2> <h2>Proxies</h2>
<span class="proxies-count">{{ filteredProxies.length }}</span> <span class="proxies-count">{{ total }}</span>
</div> </div>
<el-input <el-input
v-model="proxySearch" v-model="proxySearch"
@@ -100,21 +100,32 @@
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
<span>Loading...</span> <span>Loading...</span>
</div> </div>
<div v-else-if="filteredProxies.length > 0" class="proxies-list"> <div v-else-if="proxies.length > 0" class="proxies-list">
<ProxyCard <ProxyCard
v-for="proxy in filteredProxies" v-for="proxy in proxies"
:key="proxy.name" :key="`${proxy.type}:${proxy.name}`"
:proxy="proxy" :proxy="proxy"
show-type show-type
/> />
</div> </div>
<div v-else-if="clientProxies.length > 0" class="empty-state"> <div v-else-if="proxySearch.trim()" class="empty-state">
<p>No proxies match "{{ proxySearch }}"</p> <p>No proxies match "{{ proxySearch }}"</p>
</div> </div>
<div v-else class="empty-state"> <div v-else class="empty-state">
<p>No proxies found</p> <p>No proxies found</p>
</div> </div>
</div> </div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div> </div>
</template> </template>
@@ -130,13 +141,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue' import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client' import { Client } from '../utils/client'
import { getClient } from '../api/client' import { getClientV2 } from '../api/client'
import { getProxiesByType } from '../api/proxy' import { getProxiesV2 } from '../api/proxy'
import { import {
BaseProxy, BaseProxy,
TCPProxy, TCPProxy,
@@ -149,6 +160,8 @@ import {
} from '../utils/proxy' } from '../utils/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue' import ProxyCard from '../components/ProxyCard.vue'
import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -163,119 +176,191 @@ const goBack = () => {
} }
} }
const proxiesLoading = ref(false) const proxiesLoading = ref(false)
const allProxies = ref<BaseProxy[]>([]) const proxies = ref<BaseProxy[]>([])
const proxySearch = ref('') const proxySearch = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let requestSeq = 0
let searchDebounceTimer: number | null = null
let serverInfo: { let serverInfoPromise: Promise<ServerInfo> | null = null
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const clientProxies = computed(() => { const fetchServerInfo = (): Promise<ServerInfo> => {
if (!client.value) return [] if (!serverInfoPromise) {
return allProxies.value.filter( serverInfoPromise = getServerInfo().catch((err) => {
(p) => serverInfoPromise = null
p.clientID === client.value!.clientID && p.user === client.value!.user, throw err
) })
}) }
return serverInfoPromise
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 () => { const fetchClient = async (): Promise<boolean> => {
const key = route.params.key as string const key = route.params.key as string
if (!key) { if (!key) {
loading.value = false loading.value = false
return return false
} }
try { try {
const data = await getClient(key) const data = await getClientV2(key)
client.value = new Client(data) client.value = new Client(data)
return true
} catch (error: any) { } catch (error: any) {
ElMessage.error('Failed to fetch client: ' + error.message) ElMessage.error('Failed to fetch client: ' + error.message)
return false
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const fetchProxies = async () => { const convertProxy = async (
proxiesLoading.value = true proxy: ProxyStatsInfo,
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp'] ): Promise<BaseProxy | null> => {
const proxies: BaseProxy[] = [] const type = proxy.type || ''
try { if (type === 'tcp') {
return new TCPProxy(proxy)
}
if (type === 'udp') {
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
for (const type of proxyTypes) { if (info && info.config.vhostHTTPPort) {
try { return new HTTPProxy(
const json = await getProxiesByType(type) proxy,
if (!json.proxies) continue info.config.vhostHTTPPort,
if (type === 'tcp') { info.config.subdomainHost,
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
}
} }
allProxies.value = proxies return null
} catch { }
// Ignore 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
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 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)
} finally { } finally {
proxiesLoading.value = false if (seq === requestSeq) {
proxiesLoading.value = false
}
} }
} }
onMounted(() => { const clearSearchDebounce = () => {
fetchClient() 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
fetchProxies() fetchProxies()
}) })
</script> </script>
@@ -483,6 +568,12 @@ html.dark .status-badge.online {
padding: 16px; padding: 16px;
} }
.pagination-section {
display: flex;
justify-content: center;
padding: 0 20px 20px;
}
.proxies-list { .proxies-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -27,36 +27,6 @@
clearable clearable
class="main-search" 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>
<div class="type-tabs"> <div class="type-tabs">
@@ -111,7 +81,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElPagination } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
@@ -128,16 +98,13 @@ import {
SUDPProxy, SUDPProxy,
} from '../utils/proxy' } from '../utils/proxy'
import ProxyCard from '../components/ProxyCard.vue' import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import { import {
getProxiesV2, getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies, clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy' } from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import { getClientsV2 } from '../api/client'
import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy' import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -154,115 +121,22 @@ const proxyTypes = [
{ label: 'SUDP', value: 'sudp' }, { label: 'SUDP', value: 'sudp' },
] ]
const activeType = ref((route.params.type as string) || 'tcp') const activeType = ref((route.params.type as string) || 'all')
const proxies = ref<BaseProxy[]>([]) const proxies = ref<BaseProxy[]>([])
const clients = ref<Client[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
const showClearDialog = ref(false) 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 page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const maxV2PageSize = 100
let requestSeq = 0 let requestSeq = 0
let searchDebounceTimer: number | null = null 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 // Server info cache - cache the Promise itself so concurrent first calls
// from Promise.all (convertProxies) don't kick off multiple HTTP requests. // from Promise.all (convertProxies) don't kick off multiple HTTP requests.
type ServerInfoLite = { let serverInfoPromise: Promise<ServerInfo> | null = null
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
}
let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = (): Promise<ServerInfoLite> => { const fetchServerInfo = (): Promise<ServerInfo> => {
if (!serverInfoPromise) { if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => { serverInfoPromise = getServerInfo().catch((err) => {
// Allow retry after failure // Allow retry after failure
@@ -285,25 +159,33 @@ const convertProxy = async (
} }
if (type === 'http') { if (type === 'http') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) { if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost) return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
} }
return null return null
} }
if (type === 'https') { if (type === 'https') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) { if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost) return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
} }
return null return null
} }
if (type === 'tcpmux') { if (type === 'tcpmux') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) { if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy( return new TCPMuxProxy(
proxy, proxy,
info.tcpmuxHTTPConnectPort, info.config.tcpmuxHTTPConnectPort,
info.subdomainHost, info.config.subdomainHost,
) )
} }
return null return null
@@ -337,8 +219,6 @@ const fetchData = async (silent = false) => {
pageSize: pageSize.value, pageSize: pageSize.value,
type: activeType.value === 'all' ? undefined : activeType.value, type: activeType.value === 'all' ? undefined : activeType.value,
q: q || undefined, q: q || undefined,
clientID: clientIDFilter.value || undefined,
user: clientIDFilter.value ? userFilter.value : undefined,
}) })
if (seq !== requestSeq) return if (seq !== requestSeq) return
@@ -419,6 +299,18 @@ 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 for type changes
watch(activeType, (newType) => { watch(activeType, (newType) => {
clearSearchDebounce() clearSearchDebounce()
@@ -437,23 +329,15 @@ watch(searchText, () => {
}, 300) }, 300)
}) })
// Watch for route query changes (client filter) watch(() => route.query, sanitizeClientQuery)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
resetPageAndFetch()
},
)
onUnmounted(() => { onUnmounted(() => {
clearSearchDebounce() clearSearchDebounce()
}) })
// Initial fetch // Initial fetch
sanitizeClientQuery()
fetchData() fetchData()
fetchClients()
</script> </script>
<style scoped> <style scoped>
@@ -520,16 +404,11 @@ fetchClients()
flex: 1; flex: 1;
} }
.main-search :deep(.el-input__wrapper), .main-search :deep(.el-input__wrapper) {
.client-filter :deep(.el-input__wrapper) {
height: 32px; height: 32px;
border-radius: 8px; border-radius: 8px;
} }
.client-filter {
width: 240px;
}
.type-tabs { .type-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -584,10 +463,6 @@ fetchClients()
flex-direction: column; flex-direction: column;
} }
.client-filter {
width: 100%;
}
.pagination-section { .pagination-section {
justify-content: center; justify-content: center;
} }

View File

@@ -241,7 +241,7 @@ import {
Tickets, Tickets,
Location, Location,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getProxyByName } from '../api/proxy' import { getProxyByNameV2 } from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import { import {
BaseProxy, BaseProxy,
@@ -254,6 +254,7 @@ import {
SUDPProxy, SUDPProxy,
} from '../utils/proxy' } from '../utils/proxy'
import Traffic from '../components/Traffic.vue' import Traffic from '../components/Traffic.vue'
import type { ServerInfo } from '../types/server'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -275,12 +276,7 @@ const goBack = () => {
} }
} }
let serverInfo: { let serverInfo: ServerInfo | null = null
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const clientLink = computed(() => { const clientLink = computed(() => {
if (!proxy.value) return '' if (!proxy.value) return ''
@@ -365,27 +361,32 @@ const fetchProxy = async () => {
} }
try { try {
const data = await getProxyByName(name) const data = await getProxyByNameV2(name)
const info = await fetchServerInfo() const info = await fetchServerInfo()
const type = data.conf?.type || '' const config = info.config
const type = data.type || data.conf?.type || ''
if (type === 'tcp') { if (type === 'tcp') {
proxy.value = new TCPProxy(data) proxy.value = new TCPProxy(data)
} else if (type === 'udp') { } else if (type === 'udp') {
proxy.value = new UDPProxy(data) proxy.value = new UDPProxy(data)
} else if (type === 'http' && info?.vhostHTTPPort) { } else if (type === 'http' && config.vhostHTTPPort) {
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost) proxy.value = new HTTPProxy(
} else if (type === 'https' && info?.vhostHTTPSPort) { data,
config.vhostHTTPPort,
config.subdomainHost,
)
} else if (type === 'https' && config.vhostHTTPSPort) {
proxy.value = new HTTPSProxy( proxy.value = new HTTPSProxy(
data, data,
info.vhostHTTPSPort, config.vhostHTTPSPort,
info.subdomainHost, config.subdomainHost,
) )
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) { } else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
proxy.value = new TCPMuxProxy( proxy.value = new TCPMuxProxy(
data, data,
info.tcpmuxHTTPConnectPort, config.tcpmuxHTTPConnectPort,
info.subdomainHost, config.subdomainHost,
) )
} else if (type === 'stcp') { } else if (type === 'stcp') {
proxy.value = new STCPProxy(data) proxy.value = new STCPProxy(data)

View File

@@ -4,7 +4,7 @@
<el-col :xs="24" :sm="12" :lg="6"> <el-col :xs="24" :sm="12" :lg="6">
<StatCard <StatCard
label="Clients" label="Clients"
:value="data.clientCounts" :value="data.status.clientCounts"
type="clients" type="clients"
subtitle="Connected clients" subtitle="Connected clients"
to="/clients" to="/clients"
@@ -13,7 +13,7 @@
<el-col :xs="24" :sm="12" :lg="6"> <el-col :xs="24" :sm="12" :lg="6">
<StatCard <StatCard
label="Proxies" label="Proxies"
:value="data.proxyCounts" :value="proxyCounts"
type="proxies" type="proxies"
subtitle="Active proxies" subtitle="Active proxies"
to="/proxies/tcp" to="/proxies/tcp"
@@ -22,7 +22,7 @@
<el-col :xs="24" :sm="12" :lg="6"> <el-col :xs="24" :sm="12" :lg="6">
<StatCard <StatCard
label="Connections" label="Connections"
:value="data.curConns" :value="data.status.curConns"
type="connections" type="connections"
subtitle="Current connections" subtitle="Current connections"
/> />
@@ -54,7 +54,7 @@
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Inbound</div> <div class="label">Inbound</div>
<div class="value"> <div class="value">
{{ formatFileSize(data.totalTrafficIn) }} {{ formatFileSize(data.status.totalTrafficIn) }}
</div> </div>
</div> </div>
</div> </div>
@@ -66,7 +66,7 @@
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Outbound</div> <div class="label">Outbound</div>
<div class="value"> <div class="value">
{{ formatFileSize(data.totalTrafficOut) }} {{ formatFileSize(data.status.totalTrafficOut) }}
</div> </div>
</div> </div>
</div> </div>
@@ -83,7 +83,7 @@
</template> </template>
<div class="proxy-types-grid"> <div class="proxy-types-grid">
<div <div
v-for="(count, type) in data.proxyTypeCounts" v-for="(count, type) in data.status.proxyTypeCount"
:key="type" :key="type"
class="proxy-type-item" class="proxy-type-item"
v-show="count > 0" v-show="count > 0"
@@ -109,51 +109,51 @@
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="config-label">Bind Port</span> <span class="config-label">Bind Port</span>
<span class="config-value">{{ data.bindPort }}</span> <span class="config-value">{{ data.config.bindPort }}</span>
</div> </div>
<div class="config-item" v-if="data.kcpBindPort != 0"> <div class="config-item" v-if="data.config.kcpBindPort != 0">
<span class="config-label">KCP Port</span> <span class="config-label">KCP Port</span>
<span class="config-value">{{ data.kcpBindPort }}</span> <span class="config-value">{{ data.config.kcpBindPort }}</span>
</div> </div>
<div class="config-item" v-if="data.quicBindPort != 0"> <div class="config-item" v-if="data.config.quicBindPort != 0">
<span class="config-label">QUIC Port</span> <span class="config-label">QUIC Port</span>
<span class="config-value">{{ data.quicBindPort }}</span> <span class="config-value">{{ data.config.quicBindPort }}</span>
</div> </div>
<div class="config-item" v-if="data.vhostHTTPPort != 0"> <div class="config-item" v-if="data.config.vhostHTTPPort != 0">
<span class="config-label">HTTP Port</span> <span class="config-label">HTTP Port</span>
<span class="config-value">{{ data.vhostHTTPPort }}</span> <span class="config-value">{{ data.config.vhostHTTPPort }}</span>
</div> </div>
<div class="config-item" v-if="data.vhostHTTPSPort != 0"> <div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
<span class="config-label">HTTPS Port</span> <span class="config-label">HTTPS Port</span>
<span class="config-value">{{ data.vhostHTTPSPort }}</span> <span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
</div> </div>
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0"> <div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
<span class="config-label">TCPMux Port</span> <span class="config-label">TCPMux Port</span>
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span> <span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
</div> </div>
<div class="config-item" v-if="data.subdomainHost != ''"> <div class="config-item" v-if="data.config.subdomainHost != ''">
<span class="config-label">Subdomain Host</span> <span class="config-label">Subdomain Host</span>
<span class="config-value">{{ data.subdomainHost }}</span> <span class="config-value">{{ data.config.subdomainHost }}</span>
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="config-label">Max Pool Count</span> <span class="config-label">Max Pool Count</span>
<span class="config-value">{{ data.maxPoolCount }}</span> <span class="config-value">{{ data.config.maxPoolCount }}</span>
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="config-label">Max Ports/Client</span> <span class="config-label">Max Ports/Client</span>
<span class="config-value">{{ data.maxPortsPerClient }}</span> <span class="config-value">{{ maxPortsPerClientLabel }}</span>
</div> </div>
<div class="config-item" v-if="data.allowPortsStr != ''"> <div class="config-item" v-if="data.config.allowPortsStr != ''">
<span class="config-label">Allow Ports</span> <span class="config-label">Allow Ports</span>
<span class="config-value">{{ data.allowPortsStr }}</span> <span class="config-value">{{ data.config.allowPortsStr }}</span>
</div> </div>
<div class="config-item" v-if="data.tlsForce"> <div class="config-item" v-if="data.config.tlsForce">
<span class="config-label">TLS Force</span> <span class="config-label">TLS Force</span>
<el-tag size="small" type="warning">Enabled</el-tag> <el-tag size="small" type="warning">Enabled</el-tag>
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="config-label">Heartbeat Timeout</span> <span class="config-label">Heartbeat Timeout</span>
<span class="config-value">{{ data.heartbeatTimeout }}s</span> <span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
</div> </div>
</div> </div>
</el-card> </el-card>
@@ -167,69 +167,59 @@ import { formatFileSize } from '../utils/format'
import { Download, Upload } from '@element-plus/icons-vue' import { Download, Upload } from '@element-plus/icons-vue'
import StatCard from '../components/StatCard.vue' import StatCard from '../components/StatCard.vue'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import type { ServerInfo } from '../types/server'
const data = ref({ const data = ref<ServerInfo>({
version: '', version: '',
bindPort: 0, config: {
kcpBindPort: 0, bindPort: 0,
quicBindPort: 0, kcpBindPort: 0,
vhostHTTPPort: 0, quicBindPort: 0,
vhostHTTPSPort: 0, vhostHTTPPort: 0,
tcpmuxHTTPConnectPort: 0, vhostHTTPSPort: 0,
subdomainHost: '', tcpmuxHTTPConnectPort: 0,
maxPoolCount: 0, subdomainHost: '',
maxPortsPerClient: '', maxPoolCount: 0,
allowPortsStr: '', maxPortsPerClient: 0,
tlsForce: false, allowPortsStr: '',
heartbeatTimeout: 0, tlsForce: false,
clientCounts: 0, heartbeatTimeout: 0,
curConns: 0, },
proxyCounts: 0, status: {
totalTrafficIn: 0, clientCounts: 0,
totalTrafficOut: 0, curConns: 0,
proxyTypeCounts: {} as Record<string, number>, totalTrafficIn: 0,
totalTrafficOut: 0,
proxyTypeCount: {},
},
}) })
const hasActiveProxies = computed(() => { const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0) 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)
}) })
const formatTrafficTotal = () => { const formatTrafficTotal = () => {
const total = data.value.totalTrafficIn + data.value.totalTrafficOut const total =
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
return formatFileSize(total) return formatFileSize(total)
} }
const fetchData = async () => { const fetchData = async () => {
try { try {
const json = await getServerInfo() const json = await getServerInfo()
data.value.version = json.version data.value = json
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 { } catch {
ElMessage({ ElMessage({
showClose: true, showClose: true,

279
web/package-lock.json generated
View File

@@ -147,14 +147,13 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"aix" "aix"
@@ -164,14 +163,13 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -181,14 +179,13 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -198,14 +195,13 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -215,14 +211,13 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -232,14 +227,13 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -249,14 +243,13 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -266,14 +259,13 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -283,14 +275,13 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -300,14 +291,13 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -317,14 +307,13 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -334,14 +323,13 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -351,14 +339,13 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -368,14 +355,13 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -385,14 +371,13 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -402,14 +387,13 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -419,14 +403,13 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -436,14 +419,13 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -453,14 +435,13 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -470,14 +451,13 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -487,14 +467,13 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -504,14 +483,13 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@@ -521,14 +499,13 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"sunos" "sunos"
@@ -538,14 +515,13 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -555,14 +531,13 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -572,14 +547,13 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -2497,9 +2471,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@@ -3380,12 +3354,11 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3393,32 +3366,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.27.4", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.27.4", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.27.4", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.27.4", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.27.4", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.27.4", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.27.4", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.27.4", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.27.4", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.27.4", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
@@ -4789,11 +4762,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"license": "MIT", "funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@@ -6206,11 +6188,10 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -7390,12 +7371,12 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",