Compare commits

...

63 Commits
v0.69.1 ... dev

Author SHA1 Message Date
0ff5557571 chore(version): update version to LoliaFRP-CLI 0.69.3 2026-07-12 04:09:13 +08:00
bce3d5a3d8 chore(deps): use Lolia-FRP golib fork with adaptive buffer io.Join 2026-07-05 04:55:22 +08:00
33a29b04e4 fix(redirect): improve security in HTTP to HTTPS redirection 2026-07-04 14:39:41 +08:00
481ccad05e chore(version): update version to LoliaFRP-CLI 0.69.2 2026-07-04 14:32:44 +08:00
521542b796 feat(proxy): add HTTP to HTTPS redirection support 2026-07-04 14:30:52 +08:00
e487e7f96f chore(build): add support for linux/arm/v7 platform 2026-06-23 19:53:03 +08:00
2f73a38a2f fix(build): add linkname check for Android builds 2026-06-21 14:10:51 +08:00
a9eb5e6901 fix(build): disable linkname check for Android builds 2026-06-21 14:04:23 +08:00
386df7f426 chore(go.mod): update golib dependency to v0.7.4 2026-06-21 13:52:28 +08:00
79d5cc00ab chore(go.mod): update golib dependency to v0.7.4 2026-06-21 13:51:50 +08:00
d43a4e8950 Merge remote-tracking branch 'upstream/dev' into dev 2026-06-21 13:51:26 +08:00
d205f2bb35 chore(ci): update Go version and golangci-lint version
feat(linter): add new linters G118 and G704
refactor(proxy): optimize proxy status slice initialization
fix(api): clarify comment regarding URL construction
fix(deps): upgrade github.com/hashicorp/yamux to v0.1.2
docs(flags): improve DNS server flag description
refactor(controller): simplify port range return logic
refactor(featuregate): optimize enabled features string conversion
2026-06-06 11:35:40 +08:00
a2b9062f2c feat(dns): add support for DNS-over-HTTPS and update version 2026-06-06 11:12:05 +08:00
fatedier
9bde0b07de feat: paginate dashboard clients and proxies via API v2 (#5354)
Move the frps dashboard Clients and Proxies views to the paginated
/api/v2/clients and /api/v2/proxies endpoints instead of fetching all
data at once, and extend server-side proxy search so the search box
keeps working under pagination.

Frontend:
- Add V2Envelope/V2Page types and getV2 HTTP helper to api/http.ts
- Add v2 paginated fetch functions to api/client.ts and api/proxy.ts
- Add ClientV2Info and ProxyV2Info types for v2 API responses
- Rewrite Clients.vue with server-side pagination, status/user search
  filtering, and ElPagination component
- Rewrite Proxies.vue with server-side pagination, type tabs, client
  dropdown filter, and a search box that passes q to the API
- Default page size 10, selectable sizes [10, 20, 50, 100]

Backend:
- Extend /api/v2/proxies q matching to also cover online proxy spec
  fields: TCP/UDP remotePort and HTTP/HTTPS/TCPMux customDomains and
  subdomain, so dashboard search no longer needs to scan every page
- Add controller_v2 tests for the new spec-field matching
2026-06-03 14:08:45 +08:00
fatedier
c6c545289c fix: normalize web package lockfile (#5353) 2026-06-02 13:39:37 +08:00
fatedier
503afe78b7 feat: add dashboard API v2 pagination endpoints (#5351) 2026-06-01 20:09:25 +08:00
26f3af3fdd fix(deps): downgrade github.com/hashicorp/yamux to v0.1.1 2026-05-07 19:45:41 +08:00
7d79b83a39 chore(deps): update Go modules and package-lock files 2026-05-07 18:28:10 +08:00
e3ea471e44 feat(build): add support for loong64 architecture 2026-04-25 17:51:35 +08:00
e66f45d8be feat(plugin): add ReadHeaderTimeout to HTTP2HTTPS redirect 2026-03-10 19:08:38 +08:00
cc0b8d0f94 chore(version): update version to 0.67.4 2026-03-10 18:26:34 +08:00
27237542c8 feat(plugin): add HTTP2HTTPS redirect plugin implementation 2026-03-10 18:26:20 +08:00
406ea5ebee chore(version): update version to 0.67.3 2026-03-08 18:35:40 +08:00
81a92d19d5 feat(proxy): add traffic counting wrapper for connections 2026-03-08 18:33:42 +08:00
83847f32ed feat(controller): add API to close proxy by name 2026-02-21 07:42:17 +08:00
1f2c26761d refactor(release): update changelog generation process 2026-02-20 15:28:31 +08:00
c836e276a7 style(plugin): reorder import statements for clarity 2026-02-20 15:15:16 +08:00
d16f07d3e8 Merge pull request 'feat(proxy): add AutoTLS support for HTTPS plugins' (#1) from https-acme into dev
Reviewed-on: Lolia-FRP/lolia-frp#1
2026-02-20 15:07:59 +08:00
6bd5eb92c3 feat(proxy): add AutoTLS support for HTTPS plugins 2026-02-19 09:01:07 +08:00
09602f5d74 chore(gitattributes): add .gitattributes for line endings 2026-02-19 06:37:58 +08:00
d7559b39e2 Merge remote-tracking branch 'upstream/dev' into dev 2026-02-08 00:57:08 +08:00
72d147dfa5 fix(workflows): add missing architectures for build matrix 2026-02-04 19:32:24 +08:00
481121a6c2 refactor(workflows): inline image name definitions in docker-build.yml 2026-01-16 00:20:37 +08:00
be252de683 chore(workflows): remove old build and push workflow file
fix(dockerfiles): update base image to golang:1.25 for frpc and frps
2026-01-16 00:19:10 +08:00
0a99c1071b feat(release): add debug step to check tags and commits[skip ci] 2026-01-14 00:30:05 +08:00
dd37b2e199 fix(frpc): fix goroutine closure in runMultipleClientsFromFiles 2026-01-14 00:11:41 +08:00
803e548f42 fix(version): update version to LoliaFRP-CLI 0.66.3 2026-01-14 00:03:50 +08:00
2dac44ac2e fix(config): support multiple configuration files for frpc 2026-01-14 00:03:18 +08:00
655dc3cb2a fix(version): update version to LoliaFRP-CLI 0.66.2 2026-01-13 00:10:24 +08:00
9894342f46 fix(auth): support multiple authentication tokens for frpc 2026-01-13 00:04:50 +08:00
e7cc706c86 fix(vhost): correct heading text for unbound domain page 2026-01-11 14:37:17 +08:00
92ac2b9153 fix(workflow): enhance artifact handling in release process [skip ci] 2026-01-11 14:08:20 +08:00
1ed369e962 fix(workflow): remove tags specification from push event [skip ci] 2026-01-11 14:04:37 +08:00
b74a8d0232 fix(workflow): remove branch specification from build-all workflow 2026-01-11 14:01:35 +08:00
d2180081a0 fix(workflow): correct path to build-all workflow 2026-01-11 13:58:22 +08:00
51f4e065b5 fix(workflow): correct path to build-all workflow 2026-01-11 13:55:26 +08:00
e58f774086 fix(workflow): specify branch for build-all workflow 2026-01-11 13:54:26 +08:00
178e381a26 fix(workflow): update build-all workflow path to absolute 2026-01-11 13:53:33 +08:00
26b93ae3a3 chore(version): update version to LoliaFRP-CLI 0.66.1 2026-01-11 13:52:10 +08:00
a2aeee28e4 feat(vhost): enhance Not Found page with Chinese localization 2026-01-11 13:51:37 +08:00
0416caef71 feat(workflow): enable workflow_call for build-all workflow [skip ci] 2026-01-11 13:36:14 +08:00
1004473e42 feat(workflow): add release workflow for FRP binaries 2026-01-11 13:30:40 +08:00
f386996928 fix(api): update comment for URL construction security 2026-01-11 04:05:27 +08:00
4eb4b202c5 fix(controller): include session ID in analysis error log
style(log): use octal notation for file permissions
2026-01-11 04:00:52 +08:00
ac5bdad507 feat(client): add access messages for proxy services
feat(client): translate log messages to Chinese
feat(cmd): add authentication token support for API config
feat(log): implement rotating file logger with custom styles
feat(banner): add banner display function
fix(version): update version string for CLI
2026-01-11 03:53:52 +08:00
42f4ea7f87 feat(build): enhance android-arm build process and output 2026-01-11 02:00:52 +08:00
36e5ac094b feat(build): add android-arm build process and dependencies 2026-01-11 01:56:30 +08:00
72f79d3357 feat(api): add endpoint to close proxy by name 2025-12-26 01:10:43 +08:00
e1f905f63f chore: remove obsolete build-all workflow and update architectures in package script 2025-12-25 22:19:53 +08:00
eb58f09268 chore: remove funding and pull request template files 2025-12-25 22:19:50 +08:00
46955ffc80 feat(api): update kick proxy endpoint to use name only 2025-12-25 22:19:00 +08:00
2d63296576 feat(api): add endpoint to retrieve all proxies 2025-12-25 22:18:38 +08:00
a76ba823ee feat(api): add endpoint to kick proxy by name 2025-12-25 22:16:41 +08:00
75 changed files with 3596 additions and 482 deletions

1
.gitattributes vendored Normal file
View File

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

4
.github/FUNDING.yml vendored
View File

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

View File

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

194
.github/workflows/build-all.yaml vendored Normal file
View File

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

View File

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

82
.github/workflows/docker-build.yml vendored Normal file
View File

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

129
.github/workflows/release.yaml vendored Normal file
View File

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

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

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("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String()) xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
// close resources related with old workConn // close resources related with old workConn
pxy.Close() pxy.Close()

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

View File

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

View File

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

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,11 +141,13 @@ func startService(
visitorCfgs []v1.VisitorConfigurer, visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures, unsafeFeatures *security.UnsafeFeatures,
cfgFile string, cfgFile string,
nodeName string,
tunnelRemark string,
) error { ) error {
configSource := source.NewConfigSource() configSource := source.NewConfigSource()
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil { if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
return fmt.Errorf("failed to set config source: %w", err) return fmt.Errorf("failed to set config source: %w", err)
} }
aggregator := source.NewAggregator(configSource) aggregator := source.NewAggregator(configSource)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile) return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
} }

View File

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

View File

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

View File

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

View File

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

18
go.mod
View File

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

37
go.sum
View File

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

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='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64' arch_all='amd64 arm arm64'
extra_all='_ hf' extra_all='_ hf'
cd ./release cd ./release

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 instead of using system default one") cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
} }
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -243,8 +243,9 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
filterAll := proxyType == "" || proxyType == "all"
for name, proxyStats := range m.info.ProxyStatistics { for name, proxyStats := range m.info.ProxyStatistics {
if proxyStats.ProxyType != proxyType { if !filterAll && proxyStats.ProxyType != proxyType {
continue continue
} }
res = append(res, toProxyStats(name, proxyStats)) res = append(res, toProxyStats(name, proxyStats))
@@ -257,8 +258,13 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
defer m.mu.Unlock() defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName] proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok && proxyStats.ProxyType == proxyType { if ok {
res = toProxyStats(proxyName, proxyStats) // filterAll allows the "all proxies" API to look up a proxy by name without
// knowing its type (proxyType == "" or "all").
filterAll := proxyType == "" || proxyType == "all"
if filterAll || proxyStats.ProxyType == proxyType {
res = toProxyStats(proxyName, proxyStats)
}
} }
return return
} }

View File

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

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", err) log.Debugf("sid [%s] analysis error: %v", sid, err)
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error()) vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error()) cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
} }
@@ -385,7 +385,6 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if !isLast { if !isLast {
return nil return nil
} }
ports := make([]msg.PortsRange, 0, 1)
_, portStr, err := net.SplitHostPort(addr) _, portStr, err := net.SplitHostPort(addr)
if err != nil { if err != nil {
return nil return nil
@@ -394,9 +393,8 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if err != nil { if err != nil {
return nil return nil
} }
ports = append(ports, msg.PortsRange{ return []msg.PortsRange{{
From: max(port-difference-5, port-maxNumber, 1), From: max(port-difference-5, port-maxNumber, 1),
To: min(port+difference+5, port+maxNumber, 65535), To: min(port+difference+5, port+maxNumber, 65535),
}) }}
return ports
} }

View File

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

@@ -0,0 +1,111 @@
// Copyright 2026 The LoliaTeam Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
}
type HTTP2HTTPSRedirectPlugin struct {
opts *v1.HTTP2HTTPSRedirectPluginOptions
l *Listener
s *http.Server
}
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPSRedirectPlugin{
opts: opts,
l: listener,
}
p.s = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Not an open redirect: the target scheme is fixed to https and the
// host is the one the client already connected to.
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound) //nolint:gosec // G710
}),
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
return p, nil
}
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
host := strings.TrimSpace(req.Host)
if host == "" {
host = strings.TrimSpace(req.URL.Host)
}
targetHost := host
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
targetHost = parsedHost
if httpsPort == 0 && parsedPort == "443" {
httpsPort = 443
}
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
}
if httpsPort != 0 && httpsPort != 443 {
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
}
return (&url.URL{
Scheme: "https",
Host: targetHost,
Path: req.URL.Path,
RawPath: req.URL.RawPath,
RawQuery: req.URL.RawQuery,
}).String()
}
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
if connInfo.SrcAddr != nil {
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
}
_ = p.l.PutConn(wrapConn)
}
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
return v1.PluginHTTP2HTTPSRedirect
}
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
return p.s.Close()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,16 +39,25 @@ type TLS2RawPlugin struct {
tlsConfig *tls.Config tlsConfig *tls.Config
} }
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.TLS2RawPluginOptions) opts := options.(*v1.TLS2RawPluginOptions)
p := &TLS2RawPlugin{ p := &TLS2RawPlugin{
opts: opts, opts: opts,
} }
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") var tlsConfig *tls.Config
if err != nil { var err error
return nil, err if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, err
}
} else {
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
if err != nil {
return nil, err
}
} }
p.tlsConfig = tlsConfig p.tlsConfig = tlsConfig
return p, nil return p, nil

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

18
pkg/util/banner/banner.go Normal file
View File

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

View File

@@ -26,6 +26,12 @@ type GeneralResponse struct {
Msg string Msg string
} }
type V2Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
// APIHandler is a handler function that returns a response object or an error. // APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error) type APIHandler func(ctx *Context) (any, error)
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
} }
} }
} }
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := NewContext(w, r)
res, err := handler(ctx)
if err != nil {
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
code := http.StatusInternalServerError
if e, ok := err.(*Error); ok {
code = e.Code
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
}
}

View File

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

View File

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

181
pkg/util/net/doh.go Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package vhost
import (
"net"
"net/http"
"net/url"
"strconv"
"sync"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
// HTTPSRedirector tracks domains of HTTPS proxies that opted into automatic
// HTTP to HTTPS redirection. The HTTP reverse proxy consults it as a fallback
// for hosts that have no real HTTP route, so registered HTTP proxies on the
// same domain always take precedence over the redirect.
type HTTPSRedirector struct {
mu sync.RWMutex
domains map[string]int // domain -> number of proxies requesting the redirect
port int // port used in the redirect Location, as seen by browsers
}
func NewHTTPSRedirector(port int) *HTTPSRedirector {
return &HTTPSRedirector{
domains: make(map[string]int),
port: port,
}
}
func (r *HTTPSRedirector) Add(domain string) {
r.mu.Lock()
defer r.mu.Unlock()
r.domains[domain]++
}
func (r *HTTPSRedirector) Remove(domain string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.domains[domain] <= 1 {
delete(r.domains, domain)
} else {
r.domains[domain]--
}
}
func (r *HTTPSRedirector) match(host string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.domains[host] > 0
}
// Redirect responds with a redirect to the HTTPS endpoint of the requested
// host if the host opted into redirection. It reports whether the request
// was handled.
func (r *HTTPSRedirector) Redirect(rw http.ResponseWriter, req *http.Request) bool {
if req.Method == http.MethodConnect {
return false
}
host, err := httppkg.CanonicalHost(req.Host)
if err != nil || !r.match(host) {
return false
}
target := url.URL{
Scheme: "https",
Host: host,
Path: req.URL.Path,
RawPath: req.URL.RawPath,
RawQuery: req.URL.RawQuery,
}
if r.port != 443 {
target.Host = net.JoinHostPort(host, strconv.Itoa(r.port))
}
// Not an open redirect: the host is validated against the registered
// domain set above, the scheme is fixed, and only the same-host path and
// query are echoed back.
http.Redirect(rw, req, target.String(), http.StatusMovedPermanently) //nolint:gosec // G710
return true
}

View File

@@ -0,0 +1,46 @@
package vhost
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestHTTPSRedirectorRedirect(t *testing.T) {
r := NewHTTPSRedirector(443)
r.Add("a.example.com")
// registered domain, port stripped from host, path and query preserved
req := httptest.NewRequest("GET", "http://a.example.com:8080/foo?x=1", nil)
rw := httptest.NewRecorder()
require.True(t, r.Redirect(rw, req))
require.Equal(t, 301, rw.Code)
require.Equal(t, "https://a.example.com/foo?x=1", rw.Header().Get("Location"))
// unknown domain is not handled
req = httptest.NewRequest("GET", "http://b.example.com/", nil)
require.False(t, r.Redirect(httptest.NewRecorder(), req))
}
func TestHTTPSRedirectorNonDefaultPort(t *testing.T) {
r := NewHTTPSRedirector(8443)
r.Add("a.example.com")
req := httptest.NewRequest("GET", "http://a.example.com/", nil)
rw := httptest.NewRecorder()
require.True(t, r.Redirect(rw, req))
require.Equal(t, "https://a.example.com:8443/", rw.Header().Get("Location"))
}
func TestHTTPSRedirectorRefCount(t *testing.T) {
r := NewHTTPSRedirector(443)
r.Add("a.example.com")
r.Add("a.example.com")
r.Remove("a.example.com")
require.True(t, r.match("a.example.com"), "domain removed while another proxy still references it")
r.Remove("a.example.com")
require.False(t, r.match("a.example.com"))
}

View File

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

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.Tracef(l.prefixString+format, v...) log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...)
} }

View File

@@ -36,18 +36,25 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.Handle("/metrics", promhttp.Handler()) subRouter.Handle("/metrics", promhttp.Handler())
} }
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager) apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
// apis // apis
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET") subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET") subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET") subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET") subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET") subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE") subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view // view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler( subRouter.PathPrefix("/static/").Handler(

View File

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

View File

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

View File

@@ -38,21 +38,28 @@ type Controller struct {
serverCfg *v1.ServerConfig serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry clientRegistry *registry.ClientRegistry
pxyManager ProxyManager pxyManager ProxyManager
ctlManager ControlManager
} }
type ProxyManager interface { type ProxyManager interface {
GetByName(name string) (proxy.Proxy, bool) GetByName(name string) (proxy.Proxy, bool)
} }
type ControlManager interface {
CloseAllProxyByName(proxyName string) error
}
func NewController( func NewController(
serverCfg *v1.ServerConfig, serverCfg *v1.ServerConfig,
clientRegistry *registry.ClientRegistry, clientRegistry *registry.ClientRegistry,
pxyManager ProxyManager, pxyManager ProxyManager,
ctlManager ControlManager,
) *Controller { ) *Controller {
return &Controller{ return &Controller{
serverCfg: serverCfg, serverCfg: serverCfg,
clientRegistry: clientRegistry, clientRegistry: clientRegistry,
pxyManager: pxyManager, pxyManager: pxyManager,
ctlManager: ctlManager,
} }
} }
@@ -218,6 +225,24 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return proxyInfo, nil return proxyInfo, nil
} }
// POST /api/proxy/:name/close
func (c *Controller) APICloseProxyByName(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name required")
}
if c.ctlManager == nil {
return nil, fmt.Errorf("control manager unavailable")
}
if err := c.ctlManager.CloseAllProxyByName(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
return httppkg.GeneralResponse{Code: 200, Msg: "ok"}, nil
}
// DELETE /api/proxies?status=offline // DELETE /api/proxies?status=offline
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) { func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
status := ctx.Query("status") status := ctx.Query("status")

View File

@@ -0,0 +1,394 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"cmp"
"fmt"
"math"
"net/http"
"slices"
"strconv"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
)
const (
defaultV2Page = 1
defaultV2PageSize = 50
maxV2PageSize = 200
)
var apiV2ProxyTypes = []string{
string(v1.ProxyTypeTCP),
string(v1.ProxyTypeUDP),
string(v1.ProxyTypeHTTP),
string(v1.ProxyTypeHTTPS),
string(v1.ProxyTypeTCPMUX),
string(v1.ProxyTypeSTCP),
string(v1.ProxyTypeXTCP),
string(v1.ProxyTypeSUDP),
}
// /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
userStats := make(map[string]*model.V2UserResp)
for _, info := range c.clientRegistry.List() {
item := getOrCreateV2User(userStats, info.User)
item.ClientCount++
}
for _, proxyInfo := range c.listV2ProxyStats("") {
item := getOrCreateV2User(userStats, proxyInfo.User)
item.ProxyCount++
}
q := strings.ToLower(ctx.Query("q"))
items := make([]model.V2UserResp, 0, len(userStats))
for _, item := range userStats {
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
continue
}
items = append(items, *item)
}
slices.SortFunc(items, func(a, b model.V2UserResp) int {
return cmp.Compare(a.User, b.User)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/clients
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
if err != nil {
return nil, err
}
userFilter, filterByUser := queryValue(ctx, "user")
clientIDFilter := ctx.Query("clientID")
runIDFilter := ctx.Query("runID")
q := strings.ToLower(ctx.Query("q"))
records := c.clientRegistry.List()
items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records {
if filterByUser && info.User != userFilter {
continue
}
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
continue
}
if runIDFilter != "" && info.RunID != runIDFilter {
continue
}
if !matchV2StatusFilter(info.Online, statusFilter) {
continue
}
resp := buildClientInfoResp(info)
if q != "" && !matchV2ClientQuery(resp, q) {
continue
}
items = append(items, resp)
}
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
return v
}
return cmp.Compare(a.Key, b.Key)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
return c.APIClientDetail(ctx)
}
// /api/v2/proxies
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
if err != nil {
return nil, err
}
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
if err != nil {
return nil, err
}
userFilter, filterByUser := queryValue(ctx, "user")
clientIDFilter := ctx.Query("clientID")
q := strings.ToLower(ctx.Query("q"))
stats := c.listV2ProxyStats(proxyType)
items := make([]model.V2ProxyResp, 0, len(stats))
for _, ps := range stats {
resp := c.buildV2ProxyResp(ps)
if filterByUser && resp.User != userFilter {
continue
}
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
continue
}
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
continue
}
if q != "" && !matchV2ProxyQuery(resp, q) {
continue
}
items = append(items, resp)
}
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
if v := cmp.Compare(a.Type, b.Type); v != 0 {
return v
}
return cmp.Compare(a.Name, b.Name)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/proxies/{name}
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, fmt.Errorf("missing proxy name")
}
ps := mem.StatsCollector.GetProxyByName(name)
if ps == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return c.buildV2ProxyResp(ps), nil
}
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
item, ok := items[user]
if !ok {
item = &model.V2UserResp{User: user}
items[user] = item
}
return item
}
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
if err != nil {
return 0, 0, err
}
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
if err != nil {
return 0, 0, err
}
if pageSize > maxV2PageSize {
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
}
if page > math.MaxInt/pageSize {
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
}
return page, pageSize, nil
}
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
if raw == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
}
return value, nil
}
func parseV2StatusFilter(raw string) (string, error) {
status := strings.ToLower(raw)
switch status {
case "", "all", "online", "offline":
return status, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
}
}
func parseV2ProxyTypeFilter(raw string) (string, error) {
proxyType := strings.ToLower(raw)
if proxyType == "" {
return "", nil
}
if slices.Contains(apiV2ProxyTypes, proxyType) {
return proxyType, nil
}
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
}
func matchV2StatusFilter(online bool, filter string) bool {
switch filter {
case "", "all":
return true
case "online":
return online
case "offline":
return !online
default:
return true
}
}
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
total := len(items)
return model.V2PageResp[T]{
Total: total,
Page: page,
PageSize: pageSize,
Items: paginateV2Items(items, page, pageSize),
}
}
func paginateV2Items[T any](items []T, page, pageSize int) []T {
start := (page - 1) * pageSize
if start >= len(items) {
return []T{}
}
end := min(start+pageSize, len(items))
return items[start:end]
}
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
values, ok := ctx.Req.URL.Query()[key]
if !ok {
return "", false
}
if len(values) == 0 {
return "", true
}
return values[0], true
}
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
return containsV2Query(q,
item.Key,
item.User,
item.ClientID,
item.RunID,
item.Version,
item.WireProtocol,
item.Hostname,
item.ClientIP,
)
}
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
values := []string{
item.Name,
item.Type,
item.User,
item.ClientID,
item.Status.State,
}
switch spec := item.Spec.(type) {
case *model.TCPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.UDPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.HTTPOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.HTTPSOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.TCPMuxOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
}
return containsV2Query(q, values...)
}
func containsV2Query(q string, values ...string) bool {
for _, value := range values {
if strings.Contains(strings.ToLower(value), q) {
return true
}
}
return false
}
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
if proxyType != "" {
return mem.StatsCollector.GetProxiesByType(proxyType)
}
items := make([]*mem.ProxyStats, 0)
for _, t := range apiV2ProxyTypes {
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
}
return items
}
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline"
var spec any
if c.pxyManager != nil {
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
state = "online"
spec = getConfFromConfigurer(pxy.GetConfigurer())
}
}
return model.V2ProxyResp{
Name: ps.Name,
Type: ps.Type,
User: ps.User,
ClientID: ps.ClientID,
Spec: spec,
Status: model.V2ProxyStatusResp{
State: state,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime,
LastCloseTime: ps.LastCloseTime,
},
}
}

View File

@@ -0,0 +1,374 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
serverproxy "github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type v2EnvelopeForTest[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
type fakeStatsCollector struct {
proxies map[string]*mem.ProxyStats
}
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
}
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
items := make([]*mem.ProxyStats, 0)
for _, ps := range f.proxies {
if ps.Type == proxyType {
items = append(items, ps)
}
}
return items
}
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
ps := f.proxies[proxyName]
if ps != nil && ps.Type == proxyType {
return ps
}
return nil
}
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
return f.proxies[proxyName]
}
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
return nil
}
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies)
}
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", pageResp)
}
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
t.Fatalf("page data mismatch: %#v", pageResp.Data)
}
if got := pageResp.Data.Items[0].User; got != "" {
t.Fatalf("first sorted user mismatch, want empty got %q", got)
}
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
}
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
}
resp = performRequest(router, "/api/v2/clients?status=offline")
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
}
}
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients?page=0")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
t.Fatalf("error envelope mismatch: %#v", errResp)
}
resp = performRequest(router, "/api/v2/clients?pageSize=201")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
}
}
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients/alice.client-a")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
}
}
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies?type=invalid")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
}
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
if proxyResp.Data.Total != 1 {
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
}
proxyItem := proxyResp.Data.Items[0]
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
t.Fatalf("proxy item mismatch: %#v", proxyItem)
}
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
}
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
if userResp.Data.Total != 3 {
t.Fatalf("user total mismatch: %#v", userResp.Data)
}
for _, item := range userResp.Data.Items {
if item.ClientCount != 1 || item.ProxyCount != 1 {
t.Fatalf("user counts mismatch: %#v", item)
}
}
}
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
tests := []struct {
name string
item model.V2ProxyResp
q string
want bool
}{
{
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
}},
q: "6000",
want: true,
},
{
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
RemotePort: 7000,
}},
q: "7000",
want: true,
},
{
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
}},
q: ":6000",
want: false,
},
{
name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
}},
q: "app.example.com",
want: true,
},
{
name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
}},
q: "portal",
want: true,
},
{
name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
}},
q: "portal.example.com",
want: false,
},
{
name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
}},
q: "mux.example.com",
want: true,
},
{
name: "nil spec does not match spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
q: "6000",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
}
})
}
}
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/clients")
var clients []model.ClientInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
}
if len(clients) != 3 {
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
}
resp = performRequest(router, "/api/proxy/tcp")
var proxies model.GetProxyInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
}
if len(proxies.Proxies) != 2 {
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
}
var envelope httppkg.V2Response
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
}
}
func newV2TestController(t *testing.T) *Controller {
t.Helper()
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-empty": {
Name: "tcp-empty",
Type: "tcp",
User: "",
ClientID: "legacy-client",
TodayTrafficIn: 10,
TodayTrafficOut: 20,
CurConns: 1,
},
"tcp-alice": {
Name: "tcp-alice",
Type: "tcp",
User: "alice",
ClientID: "client-a",
TodayTrafficIn: 30,
TodayTrafficOut: 40,
},
"udp-bob": {
Name: "udp-bob",
Type: "udp",
User: "bob",
ClientID: "client-b",
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
clientRegistry.MarkOfflineByRunID("run-b")
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), nil)
}
func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
return router
}
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp
}
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
}
return out
}

46
server/http/model/v2.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package model
type V2PageResp[T any] struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Items []T `json:"items"`
}
type V2UserResp struct {
User string `json:"user"`
ClientCount int `json:"clientCount"`
ProxyCount int `json:"proxyCount"`
}
type V2ProxyResp struct {
Name string `json:"name"`
Type string `json:"type"`
User string `json:"user"`
ClientID string `json:"clientID"`
Spec any `json:"spec"`
Status V2ProxyStatusResp `json:"status"`
}
type V2ProxyStatusResp struct {
State string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
}

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
package proxy
import (
"sync/atomic"
"time"
)
// idleWatcher closes a proxied connection pair when no bytes have flowed in
// either direction for the configured timeout. Without it, endpoints that
// stay open at the TCP level but never send data again would pin the join
// goroutines and their transfer buffers forever.
type idleWatcher struct {
lastActive atomic.Int64 // unix nano of the last byte transferred
stopCh chan struct{}
}
func startIdleWatcher(timeout time.Duration, closeFn func()) *idleWatcher {
w := &idleWatcher{stopCh: make(chan struct{})}
w.touch()
go func() {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-w.stopCh:
return
case <-timer.C:
idle := time.Since(time.Unix(0, w.lastActive.Load()))
if idle >= timeout {
closeFn()
return
}
timer.Reset(timeout - idle)
}
}
}()
return w
}
func (w *idleWatcher) touch() {
w.lastActive.Store(time.Now().UnixNano())
}
func (w *idleWatcher) stop() {
close(w.stopCh)
}

View File

@@ -0,0 +1,52 @@
package proxy
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestIdleWatcherClosesAfterTimeout(t *testing.T) {
closed := make(chan struct{})
w := startIdleWatcher(50*time.Millisecond, func() {
close(closed)
})
defer w.stop()
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("closeFn was not called after idle timeout")
}
}
func TestIdleWatcherTouchPostponesClose(t *testing.T) {
var closedFlag atomic.Bool
w := startIdleWatcher(100*time.Millisecond, func() {
closedFlag.Store(true)
})
defer w.stop()
// keep the connection "active" well past the timeout
for range 6 {
time.Sleep(30 * time.Millisecond)
w.touch()
}
require.False(t, closedFlag.Load(), "closeFn fired despite ongoing activity")
// once activity stops, it should still close
require.Eventually(t, closedFlag.Load, time.Second, 10*time.Millisecond)
}
func TestIdleWatcherStopPreventsClose(t *testing.T) {
var closedFlag atomic.Bool
w := startIdleWatcher(50*time.Millisecond, func() {
closedFlag.Store(true)
})
w.stop()
time.Sleep(150 * time.Millisecond)
require.False(t, closedFlag.Load(), "closeFn fired after watcher was stopped")
}

View File

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

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

View File

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

View File

@@ -1,10 +1,26 @@
import { http } from './http' import { buildQueryString, http } from './http'
import type { ClientInfoData } from '../types/client' import type { V2Page } from './http'
import type { ClientInfoData, ClientListV2Params } from '../types/client'
export const getClients = () => { export const getClients = () => {
return http.get<ClientInfoData[]>('../api/clients') return http.get<ClientInfoData[]>('../api/clients')
} }
export const getClientsV2 = (params: ClientListV2Params = {}) => {
return http.getV2<V2Page<ClientInfoData>>(
`../api/v2/clients${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
user: params.user,
clientID: params.clientID || undefined,
runID: params.runID || undefined,
})}`,
)
}
export const getClient = (key: string) => { export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`) return http.get<ClientInfoData>(`../api/clients/${key}`)
} }

View File

@@ -11,6 +11,21 @@ class HTTPError extends Error {
} }
} }
export interface V2Envelope<T> {
code: number
msg: string
data: T
}
export interface V2Page<T> {
total: number
page: number
pageSize: number
items: T[]
}
type QueryParamValue = string | number | boolean | null | undefined
async function request<T>(url: string, options: RequestInit = {}): Promise<T> { async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = { const defaultOptions: RequestInit = {
credentials: 'include', credentials: 'include',
@@ -34,9 +49,55 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
return response.json() return response.json()
} }
async function requestV2<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
const envelope = (await response.json().catch(() => null)) as
| V2Envelope<T>
| null
if (!response.ok) {
throw new HTTPError(
response.status,
response.statusText,
envelope?.msg || `HTTP ${response.status}`,
)
}
if (!envelope || typeof envelope.code !== 'number') {
throw new Error('Invalid API v2 response')
}
if (envelope.code >= 400) {
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
}
return envelope.data
}
export const buildQueryString = (
params: Record<string, QueryParamValue>,
): string => {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
query.append(key, String(value))
}
const text = query.toString()
return text ? `?${text}` : ''
}
export const http = { export const http = {
get: <T>(url: string, options?: RequestInit) => get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }), request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) => post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, { request<T>(url, {
...options, ...options,

View File

@@ -1,7 +1,10 @@
import { http } from './http' import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type { import type {
GetProxyResponse, GetProxyResponse,
ProxyListV2Params,
ProxyStatsInfo, ProxyStatsInfo,
ProxyV2Info,
TrafficResponse, TrafficResponse,
} from '../types/proxy' } from '../types/proxy'
@@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`) return http.get<GetProxyResponse>(`../api/proxy/${type}`)
} }
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
const page = await http.getV2<V2Page<ProxyV2Info>>(
`../api/v2/proxies${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
type: params.type || undefined,
user: params.user,
clientID: params.clientID || undefined,
})}`,
)
return {
...page,
items: page.items.map(toLegacyProxyStats),
}
}
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
name: proxy.name,
type: proxy.type,
conf: proxy.spec,
user: proxy.user,
clientID: proxy.clientID,
todayTrafficIn: proxy.status.todayTrafficIn,
todayTrafficOut: proxy.status.todayTrafficOut,
curConns: proxy.status.curConns,
lastStartTime: proxy.status.lastStartTime,
lastCloseTime: proxy.status.lastCloseTime,
status: proxy.status.phase,
})
export const getProxy = (type: string, name: string) => { export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`) return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
} }

View File

@@ -13,3 +13,13 @@ export interface ClientInfoData {
disconnectedAt?: number disconnectedAt?: number
online: boolean online: boolean
} }
export interface ClientListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
user?: string
clientID?: string
runID?: string
}

View File

@@ -1,5 +1,6 @@
export interface ProxyStatsInfo { export interface ProxyStatsInfo {
name: string name: string
type?: string
conf: any conf: any
user: string user: string
clientID: string clientID: string
@@ -15,6 +16,34 @@ export interface GetProxyResponse {
proxies: ProxyStatsInfo[] proxies: ProxyStatsInfo[]
} }
export interface ProxyListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
type?: string
user?: string
clientID?: string
}
export interface ProxyV2Info {
name: string
type: string
user: string
clientID: string
spec: any
status: ProxyV2Status
}
export interface ProxyV2Status {
phase: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number
lastStartTime: string
lastCloseTime: string
}
export interface TrafficResponse { export interface TrafficResponse {
name: string name: string
trafficIn: number[] trafficIn: number[]

View File

@@ -16,7 +16,9 @@
> >
<span class="status-dot" :class="tab.value"></span> <span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span> <span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span> <span v-if="tab.count !== null" class="tab-count">{{
tab.count
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -33,9 +35,9 @@
</div> </div>
<div v-loading="loading" class="clients-content"> <div v-loading="loading" class="clients-content">
<div v-if="filteredClients.length > 0" class="clients-list"> <div v-if="clients.length > 0" class="clients-list">
<ClientCard <ClientCard
v-for="client in filteredClients" v-for="client in clients"
:key="client.key" :key="client.key"
:client="client" :client="client"
/> />
@@ -44,82 +46,123 @@
<el-empty description="No clients found" /> <el-empty description="No clients found" />
</div> </div>
</div> </div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client' import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue' import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client' import { getClientsV2 } from '../api/client'
const clients = ref<Client[]>([]) const clients = ref<Client[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all') const statusFilter = ref<'all' | 'online' | 'offline'>('all')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let refreshTimer: number | null = null let refreshTimer: number | null = null
let searchDebounceTimer: number | null = null
const stats = computed(() => { let requestSeq = 0
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
const statusTabs = computed(() => [ const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total }, {
{ value: 'online' as const, label: 'Online', count: stats.value.online }, value: 'all' as const,
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline }, label: 'All',
count: statusFilter.value === 'all' ? total.value : null,
},
{
value: 'online' as const,
label: 'Online',
count: statusFilter.value === 'online' ? total.value : null,
},
{
value: 'offline' as const,
label: 'Offline',
count: statusFilter.value === 'offline' ? total.value : null,
},
]) ])
const filteredClients = computed(() => { const fetchData = async (silent = false) => {
let result = clients.value const seq = ++requestSeq
if (!silent) loading.value = true
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
try { try {
const json = await getClients() const data = await getClientsV2({
clients.value = json.map((data) => new Client(data)) page: page.value,
pageSize: pageSize.value,
status: statusFilter.value,
q: searchText.value.trim(),
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
clients.value = data.items.map((item) => new Client(item))
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) { } catch (error: any) {
if (seq !== requestSeq) return
ElMessage({ ElMessage({
showClose: true, showClose: true,
message: 'Failed to fetch clients: ' + error.message, message: 'Failed to fetch clients: ' + error.message,
type: 'error', type: 'error',
}) })
} finally { } finally {
loading.value = false if (seq === requestSeq) {
loading.value = false
}
} }
} }
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const startAutoRefresh = () => { const startAutoRefresh = () => {
refreshTimer = window.setInterval(() => { refreshTimer = window.setInterval(() => {
fetchData() fetchData(true)
}, 5000) }, 5000)
} }
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
} }
} }
watch(statusFilter, () => {
resetPageAndFetch()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
onMounted(() => { onMounted(() => {
fetchData() fetchData()
startAutoRefresh() startAutoRefresh()
@@ -137,6 +193,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
stopAutoRefresh() stopAutoRefresh()
clearSearchDebounce()
}) })
</script> </script>
@@ -274,6 +331,11 @@ onUnmounted(() => {
padding: 60px 0; padding: 60px 0;
} }
.pagination-section {
display: flex;
justify-content: flex-end;
}
/* Dark mode adjustments */ /* Dark mode adjustments */
html.dark .status-tab { html.dark .status-tab {
background: var(--el-bg-color-overlay); background: var(--el-bg-color-overlay);
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
.status-tab { .status-tab {
flex-shrink: 0; flex-shrink: 0;
} }
.pagination-section {
justify-content: center;
}
} }
</style> </style>

View File

@@ -8,7 +8,7 @@
</div> </div>
<div class="actions-section"> <div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData"> <ActionButton variant="outline" size="small" @click="refreshData">
Refresh Refresh
</ActionButton> </ActionButton>
@@ -74,9 +74,9 @@
</div> </div>
<div v-loading="loading" class="proxies-content"> <div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list"> <div v-if="proxies.length > 0" class="proxies-list">
<ProxyCard <ProxyCard
v-for="proxy in filteredProxies" v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`" :key="`${proxy.type}:${proxy.name}`"
:proxy="proxy" :proxy="proxy"
:show-type="activeType === 'all'" :show-type="activeType === 'all'"
@@ -87,6 +87,18 @@
</div> </div>
</div> </div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
<ConfirmDialog <ConfirmDialog
v-model="showClearDialog" v-model="showClearDialog"
title="Clear Offline" title="Clear Offline"
@@ -99,9 +111,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue' import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue' import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
@@ -119,12 +131,13 @@ import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue' import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue' import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import { import {
getProxiesByType, getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies, clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy' } from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import { getClients } from '../api/client' import { getClientsV2 } from '../api/client'
import { Client } from '../utils/client' import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -149,6 +162,12 @@ const searchText = ref('')
const showClearDialog = ref(false) const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID as string) || '') const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '') const userFilter = ref((route.query.user as string) || '')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const maxV2PageSize = 100
let requestSeq = 0
let searchDebounceTimer: number | null = null
const clientOptions = computed(() => { const clientOptions = computed(() => {
return clients.value return clients.value
@@ -193,58 +212,6 @@ const selectedClientInList = computed(() => {
) )
}) })
const filteredProxies = computed(() => {
let result = proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
}
// Filter by search text across multiple fields
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter((p) => {
const fields: unknown[] = [
p.name,
p.type,
p.clientID,
p.user,
p.addr,
p.port,
p.customDomains,
p.subdomain,
]
return fields.some((v) => matchesSearch(v, search))
})
}
return result
})
// Normalize a field of unknown shape (string / number / array / null) to a
// lowercase string for case-insensitive substring matching. Arrays are joined
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
const matchesSearch = (value: unknown, needle: string): boolean => {
if (value === null || value === undefined) return false
let str: string
if (Array.isArray(value)) {
str = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v))
.join(' ')
} else if (typeof value === 'number') {
if (value === 0) return false
str = String(value)
} else {
str = String(value)
}
if (!str) return false
return str.toLowerCase().includes(needle)
}
const onClientFilterChange = (key: string) => { const onClientFilterChange = (key: string) => {
if (key) { if (key) {
const client = clientOptions.value.find((c) => c.key === key) const client = clientOptions.value.find((c) => c.key === key)
@@ -263,122 +230,174 @@ const onClientFilterChange = (key: string) => {
const fetchClients = async () => { const fetchClients = async () => {
try { try {
const json = await getClients() const allClients: Client[] = []
clients.value = json.map((data) => new Client(data)) let nextPage = 1
} catch { let totalClients = 0
// Ignore errors when fetching clients
do {
const data = await getClientsV2({
page: nextPage,
pageSize: maxV2PageSize,
})
allClients.push(...data.items.map((item) => new Client(item)))
totalClients = data.total
nextPage += 1
} while (allClients.length < totalClients)
clients.value = allClients
} catch (err) {
// Client dropdown is a non-critical side load; log for diagnostics
// but don't surface a toast (would compete with the main fetch error).
console.warn('Failed to fetch clients for filter:', err)
} }
} }
// Server info cache // Server info cache - cache the Promise itself so concurrent first calls
let serverInfo: { // from Promise.all (convertProxies) don't kick off multiple HTTP requests.
type ServerInfoLite = {
vhostHTTPPort: number vhostHTTPPort: number
vhostHTTPSPort: number vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number tcpmuxHTTPConnectPort: number
subdomainHost: string subdomainHost: string
} | null = null }
let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = async () => { const fetchServerInfo = (): Promise<ServerInfoLite> => {
if (serverInfo) return serverInfo if (!serverInfoPromise) {
const res = await getServerInfo() serverInfoPromise = getServerInfo().catch((err) => {
serverInfo = res // Allow retry after failure
return serverInfo serverInfoPromise = null
throw err
})
}
return serverInfoPromise
} }
const convertProxies = async ( const convertProxy = async (
type: string, proxy: ProxyStatsInfo,
json: any, ): Promise<BaseProxy | null> => {
): Promise<BaseProxy[]> => { const type = proxy.type || activeType.value
if (type === 'tcp') { if (type === 'tcp') {
return json.proxies.map((p: any) => new TCPProxy(p)) return new TCPProxy(proxy)
} }
if (type === 'udp') { if (type === 'udp') {
return json.proxies.map((p: any) => new UDPProxy(p)) return new UDPProxy(proxy)
} }
if (type === 'http') { if (type === 'http') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) { if (info && info.vhostHTTPPort) {
return json.proxies.map( return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
)
} }
return [] return null
} }
if (type === 'https') { if (type === 'https') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) { if (info && info.vhostHTTPSPort) {
return json.proxies.map( return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
)
} }
return [] return null
} }
if (type === 'tcpmux') { if (type === 'tcpmux') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) { if (info && info.tcpmuxHTTPConnectPort) {
return json.proxies.map( return new TCPMuxProxy(
(p: any) => proxy,
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost), info.tcpmuxHTTPConnectPort,
info.subdomainHost,
) )
} }
return [] return null
} }
if (type === 'stcp') { if (type === 'stcp') {
return json.proxies.map((p: any) => new STCPProxy(p)) return new STCPProxy(proxy)
} }
if (type === 'sudp') { if (type === 'sudp') {
return json.proxies.map((p: any) => new SUDPProxy(p)) return new SUDPProxy(proxy)
} }
// Fallback for types without a dedicated class (e.g. xtcp). Matches the // Fallback for types without a dedicated class (e.g. xtcp). Matches the
// pattern in ProxyDetail.vue so the type tag and meta render correctly. // pattern in ProxyDetail.vue so the type tag and meta render correctly.
return json.proxies.map((p: any) => { const bp = new BaseProxy(proxy)
const bp = new BaseProxy(p) bp.type = type
bp.type = type return bp
return bp
})
} }
const allProxyTypes = [ const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
'tcp', const converted = await Promise.all(items.map((item) => convertProxy(item)))
'udp', return converted.filter((item): item is BaseProxy => item !== null)
'http', }
'https',
'tcpmux',
'stcp',
'xtcp',
'sudp',
]
const fetchData = async () => { const fetchData = async (silent = false) => {
loading.value = true const seq = ++requestSeq
proxies.value = [] if (!silent) loading.value = true
try { try {
const type = activeType.value const q = searchText.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
type: activeType.value === 'all' ? undefined : activeType.value,
q: q || undefined,
clientID: clientIDFilter.value || undefined,
user: clientIDFilter.value ? userFilter.value : undefined,
})
if (seq !== requestSeq) return
if (type === 'all') { const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
const results = await Promise.all( if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
allProxyTypes.map(async (t) => { page.value = maxPage
const json = await getProxiesByType(t) await fetchData(silent)
return convertProxies(t, json) return
}),
)
proxies.value = results.flat()
} else {
const json = await getProxiesByType(type)
proxies.value = await convertProxies(type, json)
} }
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) { } catch (error: any) {
if (seq !== requestSeq) return
ElMessage({ ElMessage({
showClose: true, showClose: true,
message: 'Failed to fetch proxies: ' + error.message, message: 'Failed to fetch proxies: ' + error.message,
type: 'error', type: 'error',
}) })
} finally { } finally {
loading.value = false if (seq === requestSeq) {
loading.value = false
}
} }
} }
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const refreshData = () => {
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const handleClearConfirm = async () => { const handleClearConfirm = async () => {
showClearDialog.value = false showClearDialog.value = false
await clearOfflineProxies() await clearOfflineProxies()
@@ -402,20 +421,36 @@ const clearOfflineProxies = async () => {
// Watch for type changes // Watch for type changes
watch(activeType, (newType) => { watch(activeType, (newType) => {
clearSearchDebounce()
page.value = 1
// Update route but preserve query params // Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query }) router.replace({ params: { type: newType }, query: route.query })
fetchData() fetchData()
}) })
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
// Watch for route query changes (client filter) // Watch for route query changes (client filter)
watch( watch(
() => [route.query.clientID, route.query.user], () => [route.query.clientID, route.query.user],
([newClientID, newUser]) => { ([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || '' clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || '' userFilter.value = (newUser as string) || ''
resetPageAndFetch()
}, },
) )
onUnmounted(() => {
clearSearchDebounce()
})
// Initial fetch // Initial fetch
fetchData() fetchData()
fetchClients() fetchClients()
@@ -539,6 +574,11 @@ fetchClients()
padding: 60px 0; padding: 60px 0;
} }
.pagination-section {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.search-row { .search-row {
flex-direction: column; flex-direction: column;
@@ -547,5 +587,9 @@ fetchClients()
.client-filter { .client-filter {
width: 100%; width: 100%;
} }
.pagination-section {
justify-content: center;
}
} }
</style> </style>

70
web/package-lock.json generated
View File

@@ -954,76 +954,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@nuxt/kit": {
"version": "3.21.2",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
"defu": "^6.1.4",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.6.1",
"klona": "^2.0.6",
"knitwork": "^1.3.0",
"mlly": "^1.8.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"rc9": "^3.0.0",
"scule": "^1.3.0",
"semver": "^7.7.4",
"tinyglobby": "^0.2.15",
"ufo": "^1.6.3",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@nuxt/kit/node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@nuxt/kit/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@nuxt/kit/node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",