forked from Mxmilu666/frp
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd26c6961 | ||
|
|
c8c1e5116c | ||
|
|
4ec8de973f | ||
|
|
5bfcea3d0c | ||
|
|
0a1b4ab21f | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [fatedier]
|
||||||
|
custom: ["https://afdian.com/a/fatedier"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
### WHY
|
||||||
|
|
||||||
|
<!-- author to complete -->
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
name: Build FRP Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build FRP ${{ matrix.goos }}-${{ matrix.goarch }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
goos: [linux, windows, darwin, freebsd, openbsd, android]
|
|
||||||
goarch: [amd64, 386, arm, arm64]
|
|
||||||
include:
|
|
||||||
- goos: linux
|
|
||||||
goarch: loong64
|
|
||||||
exclude:
|
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
- goos: darwin
|
|
||||||
goarch: 386
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm
|
|
||||||
- goos: openbsd
|
|
||||||
goarch: arm
|
|
||||||
- goos: android
|
|
||||||
goarch: amd64
|
|
||||||
- goos: android
|
|
||||||
goarch: 386
|
|
||||||
# 排除 Android ARM 32位,在单独的 job 中处理
|
|
||||||
- goos: android
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.22'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y zip tar make gcc g++ upx
|
|
||||||
|
|
||||||
- name: Build FRP for ${{ matrix.goos }}-${{ matrix.goarch }}
|
|
||||||
run: |
|
|
||||||
mkdir -p release/packages
|
|
||||||
|
|
||||||
echo "Building for ${{ matrix.goos }}-${{ matrix.goarch }}"
|
|
||||||
|
|
||||||
# 构建版本号
|
|
||||||
make
|
|
||||||
version=$(./bin/frps --version)
|
|
||||||
echo "Detected version: $version"
|
|
||||||
|
|
||||||
export GOOS=${{ matrix.goos }}
|
|
||||||
export GOARCH=${{ matrix.goarch }}
|
|
||||||
export CGO_ENABLED=0
|
|
||||||
|
|
||||||
# 构建可执行文件
|
|
||||||
make frpc frps
|
|
||||||
|
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
|
||||||
if [ -f "./bin/frpc" ]; then mv ./bin/frpc ./bin/frpc.exe; fi
|
|
||||||
if [ -f "./bin/frps" ]; then mv ./bin/frps ./bin/frps.exe; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
out_dir="release/packages/frp_${version}_${{ matrix.goos }}_${{ matrix.goarch }}"
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
|
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
|
||||||
mv ./bin/frpc.exe "$out_dir/frpc.exe"
|
|
||||||
mv ./bin/frps.exe "$out_dir/frps.exe"
|
|
||||||
else
|
|
||||||
mv ./bin/frpc "$out_dir/frpc"
|
|
||||||
mv ./bin/frps "$out_dir/frps"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cp LICENSE "$out_dir"
|
|
||||||
cp -f conf/frpc.toml "$out_dir"
|
|
||||||
cp -f conf/frps.toml "$out_dir"
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: LoliaFrp_${{ matrix.goos }}_${{ matrix.goarch }}
|
|
||||||
path: |
|
|
||||||
release/packages/frp_*
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
build-android-arm:
|
|
||||||
name: Build FRP android-arm
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.22'
|
|
||||||
|
|
||||||
- name: Install dependencies and Android NDK
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y zip tar make gcc g++ upx wget unzip
|
|
||||||
|
|
||||||
# 下载并安装 Android NDK
|
|
||||||
echo "Downloading Android NDK..."
|
|
||||||
wget -q https://dl.google.com/android/repository/android-ndk-r26c-linux.zip
|
|
||||||
echo "Extracting Android NDK..."
|
|
||||||
unzip -q android-ndk-r26c-linux.zip
|
|
||||||
echo "NDK installed at: $PWD/android-ndk-r26c"
|
|
||||||
|
|
||||||
- name: Build FRP for android-arm
|
|
||||||
run: |
|
|
||||||
mkdir -p release/packages
|
|
||||||
mkdir -p bin
|
|
||||||
|
|
||||||
echo "Building for android-arm with CGO"
|
|
||||||
|
|
||||||
# 首先构建一次获取版本号
|
|
||||||
CGO_ENABLED=0 make
|
|
||||||
version=$(./bin/frps --version)
|
|
||||||
echo "Detected version: $version"
|
|
||||||
|
|
||||||
# 清理之前的构建
|
|
||||||
rm -rf ./bin/*
|
|
||||||
|
|
||||||
# 设置 Android ARM 交叉编译环境
|
|
||||||
export GOOS=android
|
|
||||||
export GOARCH=arm
|
|
||||||
export GOARM=7
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
export CC=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
|
|
||||||
export CXX=$PWD/android-ndk-r26c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang++
|
|
||||||
|
|
||||||
echo "Environment:"
|
|
||||||
echo "GOOS=$GOOS"
|
|
||||||
echo "GOARCH=$GOARCH"
|
|
||||||
echo "GOARM=$GOARM"
|
|
||||||
echo "CGO_ENABLED=$CGO_ENABLED"
|
|
||||||
echo "CC=$CC"
|
|
||||||
|
|
||||||
# 直接使用 go build 命令,不通过 Makefile,防止 CGO_ENABLED 被覆盖
|
|
||||||
echo "Building frps..."
|
|
||||||
go build -trimpath -ldflags "-s -w" -tags frps -o bin/frps ./cmd/frps
|
|
||||||
|
|
||||||
echo "Building frpc..."
|
|
||||||
go build -trimpath -ldflags "-s -w" -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
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Build Image and Publish to Dockerhub & GPR
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [ published ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Image tag'
|
||||||
|
required: true
|
||||||
|
default: 'test'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
name: Build Image from Dockerfile and binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# environment
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: '0'
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
# get image tag name
|
||||||
|
- name: Get Image Tag Name
|
||||||
|
run: |
|
||||||
|
if [ x${{ github.event.inputs.tag }} == x"" ]; then
|
||||||
|
echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to the GPR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GPR_TOKEN }}
|
||||||
|
|
||||||
|
# prepare image tags
|
||||||
|
- name: Prepare Image Tags
|
||||||
|
run: |
|
||||||
|
echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV
|
||||||
|
echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push frpc
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./dockerfiles/Dockerfile-for-frpc
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.TAG_FRPC }}
|
||||||
|
${{ env.TAG_FRPC_GPR }}
|
||||||
|
|
||||||
|
- name: Build and push frps
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./dockerfiles/Dockerfile-for-frps
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.TAG_FRPS }}
|
||||||
|
${{ env.TAG_FRPS_GPR }}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
name: Docker Build and Push
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: frps
|
|
||||||
dockerfile: dockerfiles/Dockerfile-for-frps
|
|
||||||
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
|
|
||||||
- name: frpc
|
|
||||||
dockerfile: dockerfiles/Dockerfile-for-frpc
|
|
||||||
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ matrix.image_name }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.dockerfile }}
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
|
|
||||||
- name: Generate image digest
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: |
|
|
||||||
echo "Image pushed to: ${{ matrix.image_name }}"
|
|
||||||
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
name: Release FRP Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: "Tag to release (e.g., v1.0.0)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# 调用 build-all workflow
|
|
||||||
build:
|
|
||||||
uses: ./.github/workflows/build-all.yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
# 创建 release
|
|
||||||
release:
|
|
||||||
name: Create Release
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get tag name
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Display artifact structure
|
|
||||||
run: |
|
|
||||||
echo "Artifact structure:"
|
|
||||||
ls -R artifacts/
|
|
||||||
|
|
||||||
- name: Organize release files
|
|
||||||
run: |
|
|
||||||
mkdir -p release_files
|
|
||||||
|
|
||||||
# 查找并复制所有压缩包
|
|
||||||
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
|
|
||||||
|
|
||||||
# 如果没有压缩包,尝试查找二进制文件并打包
|
|
||||||
if [ -z "$(ls -A release_files/)" ]; then
|
|
||||||
echo "No archives found, looking for directories to package..."
|
|
||||||
for dir in artifacts/*/; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
artifact_name=$(basename "$dir")
|
|
||||||
echo "Packaging $artifact_name"
|
|
||||||
|
|
||||||
# 检查是否是 Windows 构建
|
|
||||||
if echo "$artifact_name" | grep -q "windows"; then
|
|
||||||
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
|
|
||||||
else
|
|
||||||
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Files in release_files:"
|
|
||||||
ls -lh release_files/
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
run: |
|
|
||||||
cd release_files
|
|
||||||
if [ -n "$(ls -A .)" ]; then
|
|
||||||
sha256sum * > sha256sum.txt
|
|
||||||
cat sha256sum.txt
|
|
||||||
else
|
|
||||||
echo "No files to generate checksums for!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Debug - Check tags and commits
|
|
||||||
run: |
|
|
||||||
echo "Current tag: ${{ steps.tag.outputs.tag }}"
|
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
|
|
||||||
echo "Previous tag: $PREV_TAG"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Commits between tags:"
|
|
||||||
if [ "$PREV_TAG" != "none" ]; then
|
|
||||||
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
|
|
||||||
else
|
|
||||||
echo "First tag, showing all commits:"
|
|
||||||
git log --oneline ${{ steps.tag.outputs.tag }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build Changelog
|
|
||||||
id: changelog
|
|
||||||
uses: requarks/changelog-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
tag: ${{ steps.tag.outputs.tag }}
|
|
||||||
writeToFile: false
|
|
||||||
includeInvalidCommits: true
|
|
||||||
useGitmojis: false
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.tag.outputs.tag }}
|
|
||||||
name: Release ${{ steps.tag.outputs.tag }}
|
|
||||||
body: ${{ steps.changelog.outputs.changes }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
files: |
|
|
||||||
release_files/*
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -32,10 +32,6 @@ node_modules/
|
|||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
CLAUDE.md
|
|
||||||
.claude/
|
.claude/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# TLS
|
|
||||||
.autotls-cache
|
|
||||||
|
|||||||
+2
-39
@@ -16,9 +16,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -162,44 +160,9 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
|
|||||||
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
||||||
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("[%s] 启动失败: %v", proxyName, err)
|
xl.Warnf("[%s] start error: %v", proxyName, err)
|
||||||
} else {
|
} else {
|
||||||
xl.Infof("[%s] 成功启动隧道", proxyName)
|
xl.Infof("[%s] start proxy success", proxyName)
|
||||||
if inMsg.RemoteAddr != "" {
|
|
||||||
// Get proxy type to format access message
|
|
||||||
if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
|
|
||||||
proxyType := status.Type
|
|
||||||
remoteAddr := inMsg.RemoteAddr
|
|
||||||
var accessMsg string
|
|
||||||
|
|
||||||
switch proxyType {
|
|
||||||
case "tcp", "udp", "stcp", "xtcp", "sudp", "tcpmux":
|
|
||||||
// If remoteAddr only contains port (e.g., ":8080"), prepend server address
|
|
||||||
if strings.HasPrefix(remoteAddr, ":") {
|
|
||||||
serverAddr := ctl.sessionCtx.Common.ServerAddr
|
|
||||||
remoteAddr = serverAddr + remoteAddr
|
|
||||||
}
|
|
||||||
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
|
||||||
case "http", "https":
|
|
||||||
// Format as URL with protocol
|
|
||||||
protocol := proxyType
|
|
||||||
addr := remoteAddr
|
|
||||||
// Remove standard ports for cleaner URL
|
|
||||||
if proxyType == "http" && strings.HasSuffix(addr, ":80") {
|
|
||||||
addr = strings.TrimSuffix(addr, ":80")
|
|
||||||
} else if proxyType == "https" && strings.HasSuffix(addr, ":443") {
|
|
||||||
addr = strings.TrimSuffix(addr, ":443")
|
|
||||||
}
|
|
||||||
accessMsg = fmt.Sprintf("您可通过 %s://%s 访问您的服务", protocol, addr)
|
|
||||||
default:
|
|
||||||
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
xl.Infof("[%s] %s", proxyName, accessMsg)
|
|
||||||
} else {
|
|
||||||
xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -72,7 +70,6 @@ func NewProxy(
|
|||||||
|
|
||||||
baseProxy := BaseProxy{
|
baseProxy := BaseProxy{
|
||||||
baseCfg: pxyConf.GetBaseConfig(),
|
baseCfg: pxyConf.GetBaseConfig(),
|
||||||
configurer: pxyConf,
|
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
@@ -91,7 +88,6 @@ func NewProxy(
|
|||||||
|
|
||||||
type BaseProxy struct {
|
type BaseProxy struct {
|
||||||
baseCfg *v1.ProxyBaseConfig
|
baseCfg *v1.ProxyBaseConfig
|
||||||
configurer v1.ProxyConfigurer
|
|
||||||
clientCfg *v1.ClientCommonConfig
|
clientCfg *v1.ClientCommonConfig
|
||||||
encryptionKey []byte
|
encryptionKey []byte
|
||||||
msgTransporter transport.MessageTransporter
|
msgTransporter transport.MessageTransporter
|
||||||
@@ -111,7 +107,6 @@ func (pxy *BaseProxy) Run() error {
|
|||||||
if pxy.baseCfg.Plugin.Type != "" {
|
if pxy.baseCfg.Plugin.Type != "" {
|
||||||
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
||||||
Name: pxy.baseCfg.Name,
|
Name: pxy.baseCfg.Name,
|
||||||
HostAllowList: pxy.getPluginHostAllowList(),
|
|
||||||
VnetController: pxy.vnetController,
|
VnetController: pxy.vnetController,
|
||||||
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,39 +117,6 @@ func (pxy *BaseProxy) Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *BaseProxy) getPluginHostAllowList() []string {
|
|
||||||
dedupHosts := make([]string, 0)
|
|
||||||
addHost := func(host string) {
|
|
||||||
host = strings.TrimSpace(strings.ToLower(host))
|
|
||||||
if host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// autocert.HostWhitelist only supports exact host names.
|
|
||||||
if strings.Contains(host, "*") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !slices.Contains(dedupHosts, host) {
|
|
||||||
dedupHosts = append(dedupHosts, host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch cfg := pxy.configurer.(type) {
|
|
||||||
case *v1.HTTPProxyConfig:
|
|
||||||
for _, host := range cfg.CustomDomains {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
case *v1.HTTPSProxyConfig:
|
|
||||||
for _, host := range cfg.CustomDomains {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
case *v1.TCPMuxProxyConfig:
|
|
||||||
for _, host := range cfg.CustomDomains {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dedupHosts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pxy *BaseProxy) Close() {
|
func (pxy *BaseProxy) Close() {
|
||||||
if pxy.proxyPlugin != nil {
|
if pxy.proxyPlugin != nil {
|
||||||
pxy.proxyPlugin.Close()
|
pxy.proxyPlugin.Close()
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(delPxyNames) > 0 {
|
if len(delPxyNames) > 0 {
|
||||||
xl.Infof("隧道移除: %s", delPxyNames)
|
xl.Infof("proxy removed: %s", delPxyNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
addPxyNames := make([]string, 0)
|
addPxyNames := make([]string, 0)
|
||||||
@@ -177,6 +177,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(addPxyNames) > 0 {
|
if len(addPxyNames) > 0 {
|
||||||
xl.Infof("添加隧道: %s", addPxyNames)
|
xl.Infof("proxy added: %s", addPxyNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@ func (pxy *UDPProxy) Close() {
|
|||||||
|
|
||||||
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
|
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
|
||||||
// close resources related with old workConn
|
// close resources related with old workConn
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -260,7 +260,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||||
svr.stop()
|
svr.stop()
|
||||||
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
|
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go svr.keepControllerWorking()
|
go svr.keepControllerWorking()
|
||||||
@@ -305,7 +305,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
xl := xlog.FromContextSafe(svr.ctx)
|
xl := xlog.FromContextSafe(svr.ctx)
|
||||||
|
|
||||||
loginFunc := func() (bool, error) {
|
loginFunc := func() (bool, error) {
|
||||||
xl.Infof("尝试连接到服务器...")
|
xl.Infof("try to connect to server...")
|
||||||
dialer := &controlSessionDialer{
|
dialer := &controlSessionDialer{
|
||||||
ctx: svr.ctx,
|
ctx: svr.ctx,
|
||||||
common: svr.common,
|
common: svr.common,
|
||||||
@@ -316,7 +316,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
}
|
}
|
||||||
sessionCtx, err := dialer.Dial(svr.runID)
|
sessionCtx, err := dialer.Dial(svr.runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("连接服务器错误: %v", err)
|
xl.Warnf("connect to server error: %v", err)
|
||||||
if firstLoginExit {
|
if firstLoginExit {
|
||||||
svr.cancel(cancelErr{Err: err})
|
svr.cancel(cancelErr{Err: err})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
|
|||||||
Use: name,
|
Use: name,
|
||||||
Short: short,
|
Short: short,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
fmt.Println("frpc: the configuration file is not specified")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+2
-13
@@ -48,19 +48,8 @@ var natholeDiscoveryCmd = &cobra.Command{
|
|||||||
Short: "Discover nathole information from stun server",
|
Short: "Discover nathole information from stun server",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// ignore error here, because we can use command line parameters
|
// ignore error here, because we can use command line parameters
|
||||||
var cfg *v1.ClientCommonConfig
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
|
if err != nil {
|
||||||
loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
|
||||||
if err != nil {
|
|
||||||
cfg = &v1.ClientCommonConfig{}
|
|
||||||
if err := cfg.Complete(); err != nil {
|
|
||||||
fmt.Printf("failed to complete config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cfg = loaded
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cfg = &v1.ClientCommonConfig{}
|
cfg = &v1.ClientCommonConfig{}
|
||||||
if err := cfg.Complete(); err != nil {
|
if err := cfg.Complete(); err != nil {
|
||||||
fmt.Printf("failed to complete config: %v\n", err)
|
fmt.Printf("failed to complete config: %v\n", err)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "", "", "")
|
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -126,7 +126,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "", "", "")
|
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -141,13 +141,11 @@ func startService(
|
|||||||
visitorCfgs []v1.VisitorConfigurer,
|
visitorCfgs []v1.VisitorConfigurer,
|
||||||
unsafeFeatures *security.UnsafeFeatures,
|
unsafeFeatures *security.UnsafeFeatures,
|
||||||
cfgFile string,
|
cfgFile string,
|
||||||
nodeName string,
|
|
||||||
tunnelRemark string,
|
|
||||||
) error {
|
) error {
|
||||||
configSource := source.NewConfigSource()
|
configSource := source.NewConfigSource()
|
||||||
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
||||||
return fmt.Errorf("failed to set config source: %w", err)
|
return fmt.Errorf("failed to set config source: %w", err)
|
||||||
}
|
}
|
||||||
aggregator := source.NewAggregator(configSource)
|
aggregator := source.NewAggregator(configSource)
|
||||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile, nodeName, tunnelRemark)
|
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-252
@@ -16,11 +16,8 @@ package sub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -38,28 +35,24 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
"github.com/fatedier/frp/pkg/policy/featuregate"
|
"github.com/fatedier/frp/pkg/policy/featuregate"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
"github.com/fatedier/frp/pkg/util/banner"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFiles []string
|
cfgFile string
|
||||||
cfgDir string
|
cfgDir string
|
||||||
showVersion bool
|
showVersion bool
|
||||||
strictConfigMode bool
|
strictConfigMode bool
|
||||||
allowUnsafe []string
|
allowUnsafe []string
|
||||||
authTokens []string
|
|
||||||
|
|
||||||
bannerDisplayed bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
|
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
|
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
|
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
|
||||||
rootCmd.PersistentFlags().StringSliceVarP(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
|
|
||||||
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
||||||
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
||||||
}
|
}
|
||||||
@@ -75,30 +68,15 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
||||||
|
|
||||||
// If authTokens is provided, fetch config from API
|
|
||||||
if len(authTokens) > 0 {
|
|
||||||
err := runClientWithTokens(authTokens, unsafeFeatures)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
|
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
|
||||||
|
// Note that it's only designed for testing. It's not guaranteed to be stable.
|
||||||
if cfgDir != "" {
|
if cfgDir != "" {
|
||||||
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If multiple config files are specified, run one frpc service for each file
|
|
||||||
if len(cfgFiles) > 1 {
|
|
||||||
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not show command usage here.
|
// Do not show command usage here.
|
||||||
err := runClient(cfgFiles[0], unsafeFeatures)
|
err := runClient(cfgFile, unsafeFeatures)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -128,29 +106,6 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// Display banner first
|
|
||||||
banner.DisplayBanner()
|
|
||||||
bannerDisplayed = true
|
|
||||||
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
|
|
||||||
|
|
||||||
for _, cfgFile := range cfgFiles {
|
|
||||||
wg.Add(1)
|
|
||||||
// Add a small delay to avoid log output mixing
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
go func(path string) {
|
|
||||||
defer wg.Done()
|
|
||||||
err := runClient(path, unsafeFeatures)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
|
|
||||||
}
|
|
||||||
}(cfgFile)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
@@ -231,7 +186,7 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath, "", "")
|
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServiceWithAggregator(
|
func startServiceWithAggregator(
|
||||||
@@ -239,25 +194,12 @@ func startServiceWithAggregator(
|
|||||||
aggregator *source.Aggregator,
|
aggregator *source.Aggregator,
|
||||||
unsafeFeatures *security.UnsafeFeatures,
|
unsafeFeatures *security.UnsafeFeatures,
|
||||||
cfgFile string,
|
cfgFile string,
|
||||||
nodeName string,
|
|
||||||
tunnelRemark string,
|
|
||||||
) error {
|
) error {
|
||||||
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
||||||
|
|
||||||
// Display banner only once before starting the first service
|
|
||||||
if !bannerDisplayed {
|
|
||||||
banner.DisplayBanner()
|
|
||||||
bannerDisplayed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display node information if available
|
|
||||||
if nodeName != "" {
|
|
||||||
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
log.Infof("启动 frpc 服务 [%s]", cfgFile)
|
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
|
||||||
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
|
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
|
||||||
}
|
}
|
||||||
svr, err := client.NewService(client.ServiceOptions{
|
svr, err := client.NewService(client.ServiceOptions{
|
||||||
Common: cfg,
|
Common: cfg,
|
||||||
@@ -275,189 +217,3 @@ func startServiceWithAggregator(
|
|||||||
}
|
}
|
||||||
return svr.Run(context.Background())
|
return svr.Run(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIResponse represents the response from LoliaFRP API
|
|
||||||
type APIResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Data struct {
|
|
||||||
Config string `json:"config"`
|
|
||||||
NodeName string `json:"node_name"`
|
|
||||||
TunnelRemark string `json:"tunnel_remark"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenInfo stores parsed id and token from the -t parameter
|
|
||||||
type TokenInfo struct {
|
|
||||||
ID string
|
|
||||||
Token string
|
|
||||||
}
|
|
||||||
|
|
||||||
func runClientWithTokens(tokens []string, unsafeFeatures *security.UnsafeFeatures) error {
|
|
||||||
// Parse all tokens (format: id:token)
|
|
||||||
tokenInfos := make([]TokenInfo, 0, len(tokens))
|
|
||||||
for _, t := range tokens {
|
|
||||||
parts := strings.SplitN(t, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return fmt.Errorf("invalid token format '%s', expected 'id:token'", t)
|
|
||||||
}
|
|
||||||
tokenInfos = append(tokenInfos, TokenInfo{
|
|
||||||
ID: strings.TrimSpace(parts[0]),
|
|
||||||
Token: strings.TrimSpace(parts[1]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group tokens by token value (same token can have multiple IDs)
|
|
||||||
tokenToIDs := make(map[string][]string)
|
|
||||||
for _, ti := range tokenInfos {
|
|
||||||
tokenToIDs[ti.Token] = append(tokenToIDs[ti.Token], ti.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have multiple different tokens, start one service for each token group
|
|
||||||
if len(tokenToIDs) > 1 {
|
|
||||||
return runMultipleClientsWithTokens(tokenToIDs, unsafeFeatures)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the single token and all its IDs
|
|
||||||
var token string
|
|
||||||
var ids []string
|
|
||||||
for t, idList := range tokenToIDs {
|
|
||||||
token = t
|
|
||||||
ids = idList
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return runClientWithTokenAndIDs(token, ids, unsafeFeatures)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *security.UnsafeFeatures) error {
|
|
||||||
// Get API server address from environment variable
|
|
||||||
apiServer := os.Getenv("LOLIA_API")
|
|
||||||
if apiServer == "" {
|
|
||||||
apiServer = "https://api.lolia.link"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build URL with query parameters
|
|
||||||
url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ","))
|
|
||||||
// URL is constructed from trusted source (environment variable or hardcoded)
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create API request: %v", err)
|
|
||||||
}
|
|
||||||
// Carry client version in User-Agent so the API knows which frpc version is requesting
|
|
||||||
req.Header.Set("User-Agent", version.Full())
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch config from API: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp APIResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode API response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiResp.Code != 200 {
|
|
||||||
return fmt.Errorf("API error: %s", apiResp.Msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode base64 config
|
|
||||||
configBytes, err := base64.StdEncoding.DecodeString(apiResp.Data.Config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decode base64 config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config directly from bytes
|
|
||||||
return runClientWithConfig(configBytes, unsafeFeatures, apiResp.Data.NodeName, apiResp.Data.TunnelRemark)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMultipleClientsWithTokens(tokenToIDs map[string][]string, unsafeFeatures *security.UnsafeFeatures) error {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// Display banner first
|
|
||||||
banner.DisplayBanner()
|
|
||||||
bannerDisplayed = true
|
|
||||||
log.Infof("检测到 %d 个不同的 token,将并行启动多个 frpc 服务实例", len(tokenToIDs))
|
|
||||||
|
|
||||||
index := 0
|
|
||||||
for token, ids := range tokenToIDs {
|
|
||||||
wg.Add(1)
|
|
||||||
currentIndex := index
|
|
||||||
currentToken := token
|
|
||||||
currentIDs := ids
|
|
||||||
totalCount := len(tokenToIDs)
|
|
||||||
|
|
||||||
// Add a small delay to avoid log output mixing
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
maskedToken := currentToken
|
|
||||||
if len(maskedToken) > 6 {
|
|
||||||
maskedToken = maskedToken[:3] + "***" + maskedToken[len(maskedToken)-3:]
|
|
||||||
} else {
|
|
||||||
maskedToken = "***"
|
|
||||||
}
|
|
||||||
log.Infof("[%d/%d] 启动 token: %s (IDs: %v)", currentIndex+1, totalCount, maskedToken, currentIDs)
|
|
||||||
err := runClientWithTokenAndIDs(currentToken, currentIDs, unsafeFeatures)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\nToken [%s] 启动失败: %v\n", maskedToken, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeatures, nodeName, tunnelRemark string) error {
|
|
||||||
// Render template first
|
|
||||||
renderedBytes, err := config.RenderWithTemplate(configBytes, config.GetValues())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to render template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var allCfg v1.ClientConfig
|
|
||||||
if err := config.LoadConfigure(renderedBytes, &allCfg, strictConfigMode); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &allCfg.ClientCommonConfig
|
|
||||||
proxyCfgs := make([]v1.ProxyConfigurer, 0, len(allCfg.Proxies))
|
|
||||||
for _, c := range allCfg.Proxies {
|
|
||||||
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
|
|
||||||
}
|
|
||||||
visitorCfgs := make([]v1.VisitorConfigurer, 0, len(allCfg.Visitors))
|
|
||||||
for _, c := range allCfg.Visitors {
|
|
||||||
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Complete to fill in default values
|
|
||||||
if err := cfg.Complete(); err != nil {
|
|
||||||
return fmt.Errorf("failed to complete config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
|
|
||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
|
||||||
|
|
||||||
if len(cfg.FeatureGates) > 0 {
|
|
||||||
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
|
||||||
if warning != nil {
|
|
||||||
fmt.Printf("WARNING: %v\n", warning)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, "", nodeName, tunnelRemark)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ var verifyCmd = &cobra.Command{
|
|||||||
Use: "verify",
|
Use: "verify",
|
||||||
Short: "Verify that the configures is valid",
|
Short: "Verify that the configures is valid",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
if cfgFile == "" {
|
||||||
fmt.Println("frpc: the configuration file is not specified")
|
fmt.Println("frpc: the configuration file is not specified")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgFile := cfgFiles[0]
|
|
||||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
|
|||||||
@@ -336,14 +336,6 @@ type = "https2http"
|
|||||||
localAddr = "127.0.0.1:80"
|
localAddr = "127.0.0.1:80"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
keyPath = "./server.key"
|
||||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
|
||||||
# [proxies.plugin.autoTLS]
|
|
||||||
# enable = true
|
|
||||||
# email = "admin@example.com"
|
|
||||||
# cacheDir = "./.autotls-cache"
|
|
||||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
|
||||||
# hostAllowList = ["test.yourdomain.com"]
|
|
||||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
|
||||||
hostHeaderRewrite = "127.0.0.1"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
@@ -356,14 +348,6 @@ type = "https2https"
|
|||||||
localAddr = "127.0.0.1:443"
|
localAddr = "127.0.0.1:443"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
keyPath = "./server.key"
|
||||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
|
||||||
# [proxies.plugin.autoTLS]
|
|
||||||
# enable = true
|
|
||||||
# email = "admin@example.com"
|
|
||||||
# cacheDir = "./.autotls-cache"
|
|
||||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
|
||||||
# hostAllowList = ["test.yourdomain.com"]
|
|
||||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
|
||||||
hostHeaderRewrite = "127.0.0.1"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
@@ -377,15 +361,6 @@ localAddr = "127.0.0.1:443"
|
|||||||
hostHeaderRewrite = "127.0.0.1"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "plugin_http2https_redirect"
|
|
||||||
type = "http"
|
|
||||||
customDomains = ["test.yourdomain.com"]
|
|
||||||
[proxies.plugin]
|
|
||||||
type = "http2https_redirect"
|
|
||||||
# Optional. Defaults to 443. Set this if the HTTPS entry is exposed on a non-standard port.
|
|
||||||
# httpsPort = 443
|
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "plugin_http2http"
|
name = "plugin_http2http"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
@@ -405,14 +380,6 @@ type = "tls2raw"
|
|||||||
localAddr = "127.0.0.1:80"
|
localAddr = "127.0.0.1:80"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
keyPath = "./server.key"
|
||||||
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
|
||||||
# [proxies.plugin.autoTLS]
|
|
||||||
# enable = true
|
|
||||||
# email = "admin@example.com"
|
|
||||||
# cacheDir = "./.autotls-cache"
|
|
||||||
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
|
||||||
# hostAllowList = ["test.yourdomain.com"]
|
|
||||||
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "secret_tcp"
|
name = "secret_tcp"
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
|
||||||
github.com/charmbracelet/log v0.4.2
|
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.7.0
|
github.com/fatedier/golib v0.7.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -43,16 +41,10 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
@@ -61,10 +53,6 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
@@ -73,7 +61,6 @@ require (
|
|||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
github.com/prometheus/common v0.48.0 // indirect
|
github.com/prometheus/common v0.48.0 // indirect
|
||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
github.com/templexxx/cpu v0.1.1 // indirect
|
github.com/templexxx/cpu v0.1.1 // indirect
|
||||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
@@ -81,9 +68,7 @@ require (
|
|||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
@@ -97,5 +82,3 @@ require (
|
|||||||
|
|
||||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
||||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
||||||
|
|
||||||
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.7.4
|
|
||||||
|
|||||||
@@ -2,29 +2,13 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
|||||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/Lolia-FRP/golib v0.7.4 h1:EtUJChdgXVV9iU1xqB7PkD/1/3RgATbj3n86HbrLKMA=
|
|
||||||
github.com/Lolia-FRP/golib v0.7.4/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||||
@@ -36,12 +20,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||||
|
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
|
||||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
@@ -86,15 +70,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||||
@@ -128,9 +105,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
|
|||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
|
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
|
||||||
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
|
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
@@ -172,8 +148,6 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y
|
|||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||||
@@ -188,8 +162,6 @@ golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
@@ -217,7 +189,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ rm -rf ./release/packages
|
|||||||
mkdir -p ./release/packages
|
mkdir -p ./release/packages
|
||||||
|
|
||||||
os_all='linux windows darwin freebsd openbsd android'
|
os_all='linux windows darwin freebsd openbsd android'
|
||||||
arch_all='amd64 arm arm64'
|
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
|
||||||
extra_all='_ hf'
|
extra_all='_ hf'
|
||||||
|
|
||||||
cd ./release
|
cd ./release
|
||||||
|
|||||||
+1
-1
@@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
|
|||||||
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
|
cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days")
|
||||||
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
|
cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console")
|
||||||
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
|
cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
|
||||||
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server or DoH url (https://1.1.1.1/dns-query) instead of system default")
|
cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one")
|
||||||
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
||||||
}
|
}
|
||||||
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ type ClientCommonConfig struct {
|
|||||||
// STUN server to help penetrate NAT hole.
|
// STUN server to help penetrate NAT hole.
|
||||||
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
|
||||||
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
// DNSServer specifies a DNS server address for FRPC to use. If this value
|
||||||
// is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also
|
// is "", the default DNS will be used.
|
||||||
// supported, e.g. "https://1.1.1.1/dns-query".
|
|
||||||
DNSServer string `json:"dnsServer,omitempty"`
|
DNSServer string `json:"dnsServer,omitempty"`
|
||||||
// LoginFailExit controls whether or not the client should exit after a
|
// LoginFailExit controls whether or not the client should exit after a
|
||||||
// failed login attempt. If false, the client will retry until a login
|
// failed login attempt. If false, the client will retry until a login
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@@ -25,31 +24,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PluginHTTP2HTTPS = "http2https"
|
PluginHTTP2HTTPS = "http2https"
|
||||||
PluginHTTP2HTTPSRedirect = "http2https_redirect"
|
PluginHTTPProxy = "http_proxy"
|
||||||
PluginHTTPProxy = "http_proxy"
|
PluginHTTPS2HTTP = "https2http"
|
||||||
PluginHTTPS2HTTP = "https2http"
|
PluginHTTPS2HTTPS = "https2https"
|
||||||
PluginHTTPS2HTTPS = "https2https"
|
PluginHTTP2HTTP = "http2http"
|
||||||
PluginHTTP2HTTP = "http2http"
|
PluginSocks5 = "socks5"
|
||||||
PluginSocks5 = "socks5"
|
PluginStaticFile = "static_file"
|
||||||
PluginStaticFile = "static_file"
|
PluginUnixDomainSocket = "unix_domain_socket"
|
||||||
PluginUnixDomainSocket = "unix_domain_socket"
|
PluginTLS2Raw = "tls2raw"
|
||||||
PluginTLS2Raw = "tls2raw"
|
PluginVirtualNet = "virtual_net"
|
||||||
PluginVirtualNet = "virtual_net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||||
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||||
PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
|
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||||
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||||
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||||
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||||
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||||
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||||
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||||
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||||
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||||
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientPluginOptions interface {
|
type ClientPluginOptions interface {
|
||||||
@@ -83,29 +80,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
|
|||||||
return jsonx.Marshal(c.ClientPluginOptions)
|
return jsonx.Marshal(c.ClientPluginOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
|
|
||||||
// that terminate TLS locally.
|
|
||||||
type AutoTLSOptions struct {
|
|
||||||
Enable bool `json:"enable,omitempty"`
|
|
||||||
// Contact email for certificate expiration and important notices.
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
// Directory used to cache ACME account and certificates.
|
|
||||||
CacheDir string `json:"cacheDir,omitempty"`
|
|
||||||
// ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
|
|
||||||
CADirURL string `json:"caDirURL,omitempty"`
|
|
||||||
// Restrict certificate issuance to the listed domains.
|
|
||||||
HostAllowList []string `json:"hostAllowList,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
|
|
||||||
if o == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := *o
|
|
||||||
out.HostAllowList = slices.Clone(o.HostAllowList)
|
|
||||||
return &out
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTP2HTTPSPluginOptions struct {
|
type HTTP2HTTPSPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
LocalAddr string `json:"localAddr,omitempty"`
|
LocalAddr string `json:"localAddr,omitempty"`
|
||||||
@@ -124,21 +98,6 @@ func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
|||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
type HTTP2HTTPSRedirectPluginOptions struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
HTTPSPort int `json:"httpsPort,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
|
|
||||||
|
|
||||||
func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
|
|
||||||
if o == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := *o
|
|
||||||
return &out
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTPProxyPluginOptions struct {
|
type HTTPProxyPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
HTTPUser string `json:"httpUser,omitempty"`
|
HTTPUser string `json:"httpUser,omitempty"`
|
||||||
@@ -163,7 +122,6 @@ type HTTPS2HTTPPluginOptions struct {
|
|||||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||||
@@ -177,7 +135,6 @@ func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
|||||||
out := *o
|
out := *o
|
||||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +146,6 @@ type HTTPS2HTTPSPluginOptions struct {
|
|||||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||||
@@ -203,7 +159,6 @@ func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
|||||||
out := *o
|
out := *o
|
||||||
out.RequestHeaders = o.RequestHeaders.Clone()
|
out.RequestHeaders = o.RequestHeaders.Clone()
|
||||||
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +230,10 @@ func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TLS2RawPluginOptions struct {
|
type TLS2RawPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
LocalAddr string `json:"localAddr,omitempty"`
|
LocalAddr string `json:"localAddr,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *TLS2RawPluginOptions) Complete() {}
|
func (o *TLS2RawPluginOptions) Complete() {}
|
||||||
@@ -289,7 +243,6 @@ func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := *o
|
out := *o
|
||||||
out.AutoTLS = o.AutoTLS.Clone()
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ package validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -26,8 +24,6 @@ func ValidateClientPluginOptions(c v1.ClientPluginOptions) error {
|
|||||||
switch v := c.(type) {
|
switch v := c.(type) {
|
||||||
case *v1.HTTP2HTTPSPluginOptions:
|
case *v1.HTTP2HTTPSPluginOptions:
|
||||||
return validateHTTP2HTTPSPluginOptions(v)
|
return validateHTTP2HTTPSPluginOptions(v)
|
||||||
case *v1.HTTP2HTTPSRedirectPluginOptions:
|
|
||||||
return validateHTTP2HTTPSRedirectPluginOptions(v)
|
|
||||||
case *v1.HTTPS2HTTPPluginOptions:
|
case *v1.HTTPS2HTTPPluginOptions:
|
||||||
return validateHTTPS2HTTPPluginOptions(v)
|
return validateHTTPS2HTTPPluginOptions(v)
|
||||||
case *v1.HTTPS2HTTPSPluginOptions:
|
case *v1.HTTPS2HTTPSPluginOptions:
|
||||||
@@ -49,17 +45,10 @@ func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateHTTP2HTTPSRedirectPluginOptions(c *v1.HTTP2HTTPSRedirectPluginOptions) error {
|
|
||||||
return ValidatePort(c.HTTPSPort, "httpsPort")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
return errors.New("localAddr is required")
|
||||||
}
|
}
|
||||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
|
||||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +56,6 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
return errors.New("localAddr is required")
|
||||||
}
|
}
|
||||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
|
||||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,29 +77,5 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
return errors.New("localAddr is required")
|
||||||
}
|
}
|
||||||
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
|
||||||
return fmt.Errorf("invalid autoTLS options: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
|
|
||||||
if c == nil || !c.Enable {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if crtPath != "" || keyPath != "" {
|
|
||||||
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.CacheDir) == "" {
|
|
||||||
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
|
|
||||||
}
|
|
||||||
if len(c.HostAllowList) > 0 {
|
|
||||||
for _, host := range c.HostAllowList {
|
|
||||||
if strings.TrimSpace(host) == "" {
|
|
||||||
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,9 +243,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
filterAll := proxyType == "" || proxyType == "all"
|
|
||||||
for name, proxyStats := range m.info.ProxyStatistics {
|
for name, proxyStats := range m.info.ProxyStatistics {
|
||||||
if !filterAll && proxyStats.ProxyType != proxyType {
|
if proxyStats.ProxyType != proxyType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res = append(res, toProxyStats(name, proxyStats))
|
res = append(res, toProxyStats(name, proxyStats))
|
||||||
@@ -258,13 +257,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||||
if ok {
|
if ok && proxyStats.ProxyType == proxyType {
|
||||||
// filterAll allows the "all proxies" API to look up a proxy by name without
|
res = toProxyStats(proxyName, proxyStats)
|
||||||
// knowing its type (proxyType == "" or "all").
|
|
||||||
filterAll := proxyType == "" || proxyType == "all"
|
|
||||||
if filterAll || proxyStats.ProxyType == proxyType {
|
|
||||||
res = toProxyStats(proxyName, proxyStats)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
|
|||||||
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
||||||
vResp, cResp, err := c.analysis(session)
|
vResp, cResp, err := c.analysis(session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("sid [%s] analysis error: %v", sid, err)
|
log.Debugf("sid [%s] analysis error: %v", err)
|
||||||
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
||||||
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
||||||
}
|
}
|
||||||
@@ -385,6 +385,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
|||||||
if !isLast {
|
if !isLast {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ports := make([]msg.PortsRange, 0, 1)
|
||||||
_, portStr, err := net.SplitHostPort(addr)
|
_, portStr, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -393,8 +394,9 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []msg.PortsRange{{
|
ports = append(ports, msg.PortsRange{
|
||||||
From: max(port-difference-5, port-maxNumber, 1),
|
From: max(port-difference-5, port-maxNumber, 1),
|
||||||
To: min(port+difference+5, port+maxNumber, 65535),
|
To: min(port+difference+5, port+maxNumber, 65535),
|
||||||
}}
|
})
|
||||||
|
return ports
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
// Copyright 2026 The LoliaTeam Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
//go:build !frps
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
|
||||||
"golang.org/x/crypto/acme/autocert"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) {
|
|
||||||
if auto == nil || !auto.Enable {
|
|
||||||
return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil {
|
|
||||||
return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostSet := make(map[string]struct{})
|
|
||||||
hosts := make([]string, 0, len(auto.HostAllowList))
|
|
||||||
addHost := func(host string) {
|
|
||||||
host = strings.TrimSpace(strings.ToLower(host))
|
|
||||||
if host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.Contains(host, "*") {
|
|
||||||
log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := hostSet[host]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hostSet[host] = struct{}{}
|
|
||||||
hosts = append(hosts, host)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range auto.HostAllowList {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
if len(hosts) == 0 {
|
|
||||||
for _, host := range fallbackHosts {
|
|
||||||
addHost(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(hosts) == 0 {
|
|
||||||
return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &autocert.Manager{
|
|
||||||
Prompt: autocert.AcceptTOS,
|
|
||||||
Email: strings.TrimSpace(auto.Email),
|
|
||||||
HostPolicy: autocert.HostWhitelist(hosts...),
|
|
||||||
}
|
|
||||||
caDirURL := strings.TrimSpace(auto.CADirURL)
|
|
||||||
if caDirURL != "" {
|
|
||||||
manager.Client = &acme.Client{DirectoryURL: caDirURL}
|
|
||||||
} else {
|
|
||||||
caDirURL = autocert.DefaultACMEDirectory
|
|
||||||
}
|
|
||||||
managedHosts := make(map[string]struct{}, len(hosts))
|
|
||||||
for _, host := range hosts {
|
|
||||||
managedHosts[host] = struct{}{}
|
|
||||||
}
|
|
||||||
var warmupInProgress sync.Map
|
|
||||||
var warmupMissLogged sync.Map
|
|
||||||
manager.Cache = &autoTLSCache{
|
|
||||||
inner: autocert.DirCache(auto.CacheDir),
|
|
||||||
managedHosts: managedHosts,
|
|
||||||
pluginName: pluginName,
|
|
||||||
caDirURL: caDirURL,
|
|
||||||
warmupInProgress: &warmupInProgress,
|
|
||||||
warmupMissLogged: &warmupMissLogged,
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := manager.TLSConfig()
|
|
||||||
log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir)
|
|
||||||
|
|
||||||
var readySeen sync.Map
|
|
||||||
|
|
||||||
handleCertReady := func(host string, cert *tls.Certificate) {
|
|
||||||
var (
|
|
||||||
notAfter time.Time
|
|
||||||
hasExpiry bool
|
|
||||||
)
|
|
||||||
if t, ok := getCertificateNotAfter(cert); ok {
|
|
||||||
notAfter = t
|
|
||||||
hasExpiry = true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, readyLogged := readySeen.LoadOrStore(host, struct{}{})
|
|
||||||
if hasExpiry {
|
|
||||||
if !readyLogged {
|
|
||||||
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
} else if !readyLogged {
|
|
||||||
log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
host := strings.TrimSpace(strings.ToLower(hello.ServerName))
|
|
||||||
if host == "" {
|
|
||||||
host = "<空SNI>"
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := manager.GetCertificate(hello)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
handleCertReady(host, cert)
|
|
||||||
return cert, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warm up certificates in background after startup.
|
|
||||||
for _, host := range hosts {
|
|
||||||
h := host
|
|
||||||
go func() {
|
|
||||||
// Leave time for listener setup and route registration.
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
warmupMissLogged.Delete(h)
|
|
||||||
warmupInProgress.Store(h, struct{}{})
|
|
||||||
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
|
|
||||||
warmupInProgress.Delete(h)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleCertReady(h, cert)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) {
|
|
||||||
if cert == nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
if cert.Leaf != nil {
|
|
||||||
return cert.Leaf.NotAfter, true
|
|
||||||
}
|
|
||||||
if len(cert.Certificate) == 0 {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return leaf.NotAfter, true
|
|
||||||
}
|
|
||||||
|
|
||||||
type autoTLSCache struct {
|
|
||||||
inner autocert.Cache
|
|
||||||
managedHosts map[string]struct{}
|
|
||||||
pluginName string
|
|
||||||
caDirURL string
|
|
||||||
warmupInProgress *sync.Map
|
|
||||||
warmupMissLogged *sync.Map
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) {
|
|
||||||
data, err := c.inner.Get(ctx, key)
|
|
||||||
if err != autocert.ErrCacheMiss {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := strings.TrimSuffix(key, "+rsa")
|
|
||||||
if _, ok := c.managedHosts[host]; !ok {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
if _, warming := c.warmupInProgress.Load(host); !warming {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded {
|
|
||||||
log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-01,caDirURL=%s", c.pluginName, host, c.caDirURL)
|
|
||||||
}
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *autoTLSCache) Put(ctx context.Context, key string, data []byte) error {
|
|
||||||
return c.inner.Put(ctx, key, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *autoTLSCache) Delete(ctx context.Context, key string) error {
|
|
||||||
return c.inner.Delete(ctx, key)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Copyright 2026 The LoliaTeam Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
//go:build !frps
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Register(v1.PluginHTTP2HTTPSRedirect, NewHTTP2HTTPSRedirectPlugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTP2HTTPSRedirectPlugin struct {
|
|
||||||
opts *v1.HTTP2HTTPSRedirectPluginOptions
|
|
||||||
|
|
||||||
l *Listener
|
|
||||||
s *http.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHTTP2HTTPSRedirectPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
|
||||||
opts := options.(*v1.HTTP2HTTPSRedirectPluginOptions)
|
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
p := &HTTP2HTTPSRedirectPlugin{
|
|
||||||
opts: opts,
|
|
||||||
l: listener,
|
|
||||||
}
|
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
||||||
http.Redirect(w, req, buildHTTPSRedirectURL(req, opts.HTTPSPort), http.StatusFound)
|
|
||||||
}),
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildHTTPSRedirectURL(req *http.Request, httpsPort int) string {
|
|
||||||
host := strings.TrimSpace(req.Host)
|
|
||||||
if host == "" {
|
|
||||||
host = strings.TrimSpace(req.URL.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetHost := host
|
|
||||||
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
|
|
||||||
targetHost = parsedHost
|
|
||||||
if httpsPort == 0 && parsedPort == "443" {
|
|
||||||
httpsPort = 443
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
||||||
targetHost = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
|
||||||
}
|
|
||||||
|
|
||||||
if httpsPort != 0 && httpsPort != 443 {
|
|
||||||
targetHost = net.JoinHostPort(targetHost, strconv.Itoa(httpsPort))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (&url.URL{
|
|
||||||
Scheme: "https",
|
|
||||||
Host: targetHost,
|
|
||||||
Path: req.URL.Path,
|
|
||||||
RawPath: req.URL.RawPath,
|
|
||||||
RawQuery: req.URL.RawQuery,
|
|
||||||
}).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSRedirectPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
if connInfo.SrcAddr != nil {
|
|
||||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
|
||||||
}
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSRedirectPlugin) Name() string {
|
|
||||||
return v1.PluginHTTP2HTTPSRedirect
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSRedirectPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
stdlog "log"
|
stdlog "log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -81,27 +80,11 @@ func newHTTPSBridgePluginServer(
|
|||||||
enableHTTP2 *bool,
|
enableHTTP2 *bool,
|
||||||
useSourceRemoteAddr bool,
|
useSourceRemoteAddr bool,
|
||||||
) (*httpBridgePlugin, error) {
|
) (*httpBridgePlugin, error) {
|
||||||
|
listener := NewProxyListener()
|
||||||
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
|
|
||||||
// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
|
|
||||||
func newHTTPSBridgePluginServerWithTLSConfig(
|
|
||||||
handler http.Handler,
|
|
||||||
tlsConfig *tls.Config,
|
|
||||||
enableHTTP2 *bool,
|
|
||||||
useSourceRemoteAddr bool,
|
|
||||||
) *httpBridgePlugin {
|
|
||||||
server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
|
|
||||||
return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
|
|
||||||
listener := NewProxyListener()
|
|
||||||
p := &httpBridgePlugin{
|
p := &httpBridgePlugin{
|
||||||
l: listener,
|
l: listener,
|
||||||
s: server,
|
s: server,
|
||||||
@@ -110,7 +93,7 @@ func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool
|
|||||||
go func() {
|
go func() {
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
_ = p.s.ServeTLS(listener, "", "")
|
||||||
}()
|
}()
|
||||||
return p
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHTTPBridgeReverseProxy(
|
func newHTTPBridgeReverseProxy(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
@@ -33,7 +32,7 @@ type HTTPS2HTTPPlugin struct {
|
|||||||
*httpBridgePlugin
|
*httpBridgePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||||
|
|
||||||
p := &HTTPS2HTTPPlugin{
|
p := &HTTPS2HTTPPlugin{
|
||||||
@@ -50,15 +49,6 @@ func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions
|
|||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
|
||||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
|
||||||
}
|
|
||||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ type HTTPS2HTTPSPlugin struct {
|
|||||||
*httpBridgePlugin
|
*httpBridgePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||||
|
|
||||||
p := &HTTPS2HTTPSPlugin{
|
p := &HTTPS2HTTPSPlugin{
|
||||||
@@ -56,15 +55,6 @@ func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOption
|
|||||||
tr,
|
tr,
|
||||||
)
|
)
|
||||||
|
|
||||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
|
||||||
tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
|
||||||
}
|
|
||||||
p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -33,12 +33,7 @@ func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*htt
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||||
}
|
}
|
||||||
return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
|
|
||||||
// It is used by features such as autoTLS that supply their own certificate provider.
|
|
||||||
func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: withMisdirectedRequestCheck(handler),
|
Handler: withMisdirectedRequestCheck(handler),
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
@@ -47,7 +42,7 @@ func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *
|
|||||||
if !lo.FromPtr(enableHTTP2) {
|
if !lo.FromPtr(enableHTTP2) {
|
||||||
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
||||||
}
|
}
|
||||||
return server
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import (
|
|||||||
|
|
||||||
type PluginContext struct {
|
type PluginContext struct {
|
||||||
Name string
|
Name string
|
||||||
HostAllowList []string
|
|
||||||
VnetController *vnet.Controller
|
VnetController *vnet.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,25 +39,16 @@ type TLS2RawPlugin struct {
|
|||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.TLS2RawPluginOptions)
|
opts := options.(*v1.TLS2RawPluginOptions)
|
||||||
|
|
||||||
p := &TLS2RawPlugin{
|
p := &TLS2RawPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||||
var err error
|
if err != nil {
|
||||||
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
return nil, err
|
||||||
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
p.tlsConfig = tlsConfig
|
p.tlsConfig = tlsConfig
|
||||||
return p, nil
|
return p, nil
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
charmlog "github.com/charmbracelet/log"
|
goliblog "github.com/fatedier/golib/log"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
frplog "github.com/fatedier/frp/pkg/util/log"
|
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||||
@@ -39,8 +39,8 @@ type testPlugin struct {
|
|||||||
var logCaptureMu sync.Mutex
|
var logCaptureMu sync.Mutex
|
||||||
|
|
||||||
type logCapture struct {
|
type logCapture struct {
|
||||||
mu sync.Mutex
|
bytes.Buffer
|
||||||
buf bytes.Buffer
|
levels []goliblog.Level
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p testPlugin) Name() string {
|
func (p testPlugin) Name() string {
|
||||||
@@ -55,36 +55,9 @@ func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Respon
|
|||||||
return p.handler(ctx, op, content)
|
return p.handler(ctx, op, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *logCapture) Write(p []byte) (int, error) {
|
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
||||||
w.mu.Lock()
|
w.levels = append(w.levels, level)
|
||||||
defer w.mu.Unlock()
|
return w.Write(p)
|
||||||
return w.buf.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *logCapture) String() string {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
return w.buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// levels parses the captured JSON log output and returns the level of each entry.
|
|
||||||
func (w *logCapture) levels() []charmlog.Level {
|
|
||||||
var levels []charmlog.Level
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(w.String()), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var entry struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
|
|
||||||
levels = append(levels, lvl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return levels
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureLogOutput(t *testing.T) *logCapture {
|
func captureLogOutput(t *testing.T) *logCapture {
|
||||||
@@ -93,10 +66,11 @@ func captureLogOutput(t *testing.T) *logCapture {
|
|||||||
logCaptureMu.Lock()
|
logCaptureMu.Lock()
|
||||||
logOutput := &logCapture{}
|
logOutput := &logCapture{}
|
||||||
oldLogger := frplog.Logger
|
oldLogger := frplog.Logger
|
||||||
frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
|
frplog.Logger = goliblog.New(
|
||||||
Level: charmlog.DebugLevel,
|
goliblog.WithOutput(logOutput),
|
||||||
Formatter: charmlog.JSONFormatter,
|
goliblog.WithLevel(goliblog.TraceLevel),
|
||||||
})
|
goliblog.WithCaller(false),
|
||||||
|
)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
frplog.Logger = oldLogger
|
frplog.Logger = oldLogger
|
||||||
logCaptureMu.Unlock()
|
logCaptureMu.Unlock()
|
||||||
@@ -289,10 +263,10 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
op string
|
op string
|
||||||
level charmlog.Level
|
level goliblog.Level
|
||||||
}{
|
}{
|
||||||
{name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
|
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
||||||
{name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
|
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -314,9 +288,8 @@ func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
|||||||
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
levels := logOutput.levels()
|
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
||||||
if len(levels) != 1 || levels[0] != tt.level {
|
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
||||||
t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -352,13 +325,12 @@ func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
||||||
t.Fatalf("missing aggregated errors: %v", err)
|
t.Fatalf("missing aggregated errors: %v", err)
|
||||||
}
|
}
|
||||||
levels := logOutput.levels()
|
if len(logOutput.levels) != 2 {
|
||||||
if len(levels) != 2 {
|
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
||||||
t.Fatalf("expected two warning logs, got %v", levels)
|
|
||||||
}
|
}
|
||||||
for _, level := range levels {
|
for _, level := range logOutput.levels {
|
||||||
if level != charmlog.WarnLevel {
|
if level != goliblog.WarnLevel {
|
||||||
t.Fatalf("expected warning log level, got %v", levels)
|
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package banner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DisplayBanner() {
|
|
||||||
fmt.Println(" __ ___ __________ ____ ________ ____")
|
|
||||||
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
|
|
||||||
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
|
|
||||||
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
|
|
||||||
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
|
|
||||||
fmt.Println(" ")
|
|
||||||
log.Infof("Nya! %s 启动中", version.Full())
|
|
||||||
}
|
|
||||||
@@ -26,12 +26,6 @@ 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)
|
||||||
|
|
||||||
@@ -70,27 +64,3 @@ 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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+27
-158
@@ -16,18 +16,13 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/fatedier/golib/log"
|
||||||
"github.com/charmbracelet/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TraceLevel = log.DebugLevel
|
TraceLevel = log.TraceLevel
|
||||||
DebugLevel = log.DebugLevel
|
DebugLevel = log.DebugLevel
|
||||||
InfoLevel = log.InfoLevel
|
InfoLevel = log.InfoLevel
|
||||||
WarnLevel = log.WarnLevel
|
WarnLevel = log.WarnLevel
|
||||||
@@ -37,158 +32,39 @@ var (
|
|||||||
var Logger *log.Logger
|
var Logger *log.Logger
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Logger = log.NewWithOptions(os.Stderr, log.Options{
|
Logger = log.New(
|
||||||
ReportCaller: true,
|
log.WithCaller(true),
|
||||||
ReportTimestamp: true,
|
log.AddCallerSkip(1),
|
||||||
TimeFormat: time.Kitchen,
|
log.WithLevel(log.InfoLevel),
|
||||||
Prefix: "LoliaFRP-CLI",
|
)
|
||||||
CallerOffset: 1,
|
|
||||||
})
|
|
||||||
// 设置自定义样式以支持 Trace 级别
|
|
||||||
styles := log.DefaultStyles()
|
|
||||||
styles.Levels[TraceLevel] = lipgloss.NewStyle().
|
|
||||||
SetString("TRACE").
|
|
||||||
Bold(true).
|
|
||||||
MaxWidth(5).
|
|
||||||
Foreground(lipgloss.Color("61"))
|
|
||||||
Logger.SetStyles(styles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
||||||
var output io.Writer
|
options := []log.Option{}
|
||||||
var err error
|
|
||||||
|
|
||||||
if logPath == "console" {
|
if logPath == "console" {
|
||||||
output = os.Stdout
|
if !disableLogColor {
|
||||||
} else {
|
options = append(options,
|
||||||
// Use rotating file writer
|
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
|
||||||
output, err = NewRotateFileWriter(logPath, maxDays)
|
Colorful: true,
|
||||||
if err != nil {
|
}, os.Stdout)),
|
||||||
// Fallback to console if file creation fails
|
)
|
||||||
output = os.Stdout
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
writer := log.NewRotateFileWriter(log.RotateFileConfig{
|
||||||
|
FileName: logPath,
|
||||||
|
Mode: log.RotateFileModeDaily,
|
||||||
|
MaxDays: maxDays,
|
||||||
|
})
|
||||||
|
writer.Init()
|
||||||
|
options = append(options, log.WithOutput(writer))
|
||||||
}
|
}
|
||||||
|
|
||||||
level, err := log.ParseLevel(levelStr)
|
level, err := log.ParseLevel(levelStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
level = log.InfoLevel
|
level = log.InfoLevel
|
||||||
}
|
}
|
||||||
|
options = append(options, log.WithLevel(level))
|
||||||
Logger = log.NewWithOptions(output, log.Options{
|
Logger = Logger.WithOptions(options...)
|
||||||
ReportCaller: true,
|
|
||||||
ReportTimestamp: true,
|
|
||||||
TimeFormat: time.Kitchen,
|
|
||||||
Prefix: "LoliaFRP-CLI",
|
|
||||||
CallerOffset: 1,
|
|
||||||
Level: level,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRotateFileWriter creates a rotating file writer
|
|
||||||
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
|
|
||||||
w := &RotateFileWriter{
|
|
||||||
filePath: filePath,
|
|
||||||
maxDays: maxDays,
|
|
||||||
lastRotate: time.Now(),
|
|
||||||
currentDate: time.Now().Format("2006-01-02"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.openFile(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotateFileWriter implements io.Writer with daily rotation
|
|
||||||
type RotateFileWriter struct {
|
|
||||||
filePath string
|
|
||||||
maxDays int
|
|
||||||
file *os.File
|
|
||||||
lastRotate time.Time
|
|
||||||
currentDate string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) openFile() error {
|
|
||||||
var err error
|
|
||||||
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) checkRotate() error {
|
|
||||||
now := time.Now()
|
|
||||||
currentDate := now.Format("2006-01-02")
|
|
||||||
|
|
||||||
if currentDate != w.currentDate {
|
|
||||||
// Close current file
|
|
||||||
if w.file != nil {
|
|
||||||
w.file.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename current file with date suffix
|
|
||||||
oldPath := w.filePath
|
|
||||||
newPath := w.filePath + "." + w.currentDate
|
|
||||||
if _, err := os.Stat(oldPath); err == nil {
|
|
||||||
if err := os.Rename(oldPath, newPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old log files
|
|
||||||
w.cleanupOldLogs(now)
|
|
||||||
|
|
||||||
// Update current date and open new file
|
|
||||||
w.currentDate = currentDate
|
|
||||||
w.lastRotate = now
|
|
||||||
return w.openFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
|
|
||||||
if w.maxDays <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoffDate := now.AddDate(0, 0, -w.maxDays)
|
|
||||||
|
|
||||||
// Find and remove old log files
|
|
||||||
dir := filepath.Dir(w.filePath)
|
|
||||||
base := filepath.Base(w.filePath)
|
|
||||||
|
|
||||||
files, _ := os.ReadDir(dir)
|
|
||||||
for _, f := range files {
|
|
||||||
if f.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := f.Name()
|
|
||||||
if strings.HasPrefix(name, base+".") {
|
|
||||||
// Extract date from filename (base.YYYY-MM-DD)
|
|
||||||
dateStr := strings.TrimPrefix(name, base+".")
|
|
||||||
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) {
|
||||||
@@ -199,10 +75,6 @@ func Warnf(format string, v ...any) {
|
|||||||
Logger.Warnf(format, v...)
|
Logger.Warnf(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Info(format string, v ...any) {
|
|
||||||
Logger.Info(format, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Infof(format string, v ...any) {
|
func Infof(format string, v ...any) {
|
||||||
Logger.Infof(format, v...)
|
Logger.Infof(format, v...)
|
||||||
}
|
}
|
||||||
@@ -212,12 +84,11 @@ func Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Tracef(format string, v ...any) {
|
func Tracef(format string, v ...any) {
|
||||||
Logger.Logf(TraceLevel, format, v...)
|
Logger.Tracef(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logf(level log.Level, offset int, format string, v ...any) {
|
func Logf(level log.Level, offset int, format string, v ...any) {
|
||||||
// charmbracelet/log doesn't support offset, so we ignore it
|
Logger.Logf(level, offset, format, v...)
|
||||||
Logger.Logf(level, format, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteLogger struct {
|
type WriteLogger struct {
|
||||||
@@ -233,8 +104,6 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
||||||
// charmbracelet/log doesn't support offset in Log
|
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
|
||||||
msg := string(bytes.TrimRight(p, "\n"))
|
|
||||||
Logger.Log(w.level, msg)
|
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,9 @@ package net
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetDefaultDNSAddress(dnsAddress string) {
|
func SetDefaultDNSAddress(dnsAddress string) {
|
||||||
// DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query
|
|
||||||
if strings.HasPrefix(dnsAddress, "https://") {
|
|
||||||
SetDefaultDNSOverHTTPS(dnsAddress)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
if _, _, err := net.SplitHostPort(dnsAddress); err != nil {
|
||||||
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
dnsAddress = net.JoinHostPort(dnsAddress, "53")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
// Copyright 2026 The Lolia Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package net
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
dohMimeType = "application/dns-message"
|
|
||||||
dohMaxResponseSize = 65535
|
|
||||||
dohRequestTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends
|
|
||||||
// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint,
|
|
||||||
// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query".
|
|
||||||
func SetDefaultDNSOverHTTPS(dohURL string) {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: dohRequestTimeout,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: dohRequestTimeout,
|
|
||||||
// Use a fresh resolver to look up the DoH server hostname itself,
|
|
||||||
// avoiding infinite recursion through net.DefaultResolver.
|
|
||||||
Resolver: &net.Resolver{},
|
|
||||||
}).DialContext,
|
|
||||||
MaxIdleConns: 10,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
|
||||||
ForceAttemptHTTP2: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
net.DefaultResolver = &net.Resolver{
|
|
||||||
PreferGo: true,
|
|
||||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
||||||
return &dohConn{
|
|
||||||
ctx: ctx,
|
|
||||||
client: client,
|
|
||||||
url: dohURL,
|
|
||||||
network: network,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dohConn adapts the DNS wire-format messages exchanged by net.Resolver
|
|
||||||
// into DNS-over-HTTPS requests. Since dohConn does not implement
|
|
||||||
// net.PacketConn, the resolver always uses stream (TCP-style) framing with a
|
|
||||||
// 2-byte big-endian length prefix, regardless of the dialed network. The
|
|
||||||
// resolver writes a query and then reads the response from the same
|
|
||||||
// connection; the HTTP round trip happens synchronously inside Write.
|
|
||||||
type dohConn struct {
|
|
||||||
ctx context.Context
|
|
||||||
client *http.Client
|
|
||||||
url string
|
|
||||||
network string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
deadline time.Time
|
|
||||||
reqBuf bytes.Buffer // unprocessed request bytes
|
|
||||||
respBuf bytes.Buffer // response stream with 2-byte length prefixes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) Write(b []byte) (int, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.reqBuf.Write(b)
|
|
||||||
for {
|
|
||||||
data := c.reqBuf.Bytes()
|
|
||||||
if len(data) < 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
msgLen := int(binary.BigEndian.Uint16(data))
|
|
||||||
if len(data) < 2+msgLen {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
msg := make([]byte, msgLen)
|
|
||||||
copy(msg, data[2:2+msgLen])
|
|
||||||
c.reqBuf.Next(2 + msgLen)
|
|
||||||
|
|
||||||
resp, err := c.roundTrip(msg)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
var lenBuf [2]byte
|
|
||||||
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp)))
|
|
||||||
c.respBuf.Write(lenBuf[:])
|
|
||||||
c.respBuf.Write(resp)
|
|
||||||
}
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) Read(b []byte) (int, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.respBuf.Len() == 0 {
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
return c.respBuf.Read(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) roundTrip(query []byte) ([]byte, error) {
|
|
||||||
ctx := c.ctx
|
|
||||||
if !c.deadline.IsZero() {
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
ctx, cancel = context.WithDeadline(ctx, c.deadline)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", dohMimeType)
|
|
||||||
req.Header.Set("Accept", dohMimeType)
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(body) > dohMaxResponseSize {
|
|
||||||
return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize)
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) Close() error { return nil }
|
|
||||||
|
|
||||||
func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} }
|
|
||||||
func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} }
|
|
||||||
|
|
||||||
func (c *dohConn) SetDeadline(t time.Time) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
c.deadline = t
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
|
|
||||||
func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) }
|
|
||||||
|
|
||||||
type dohAddr struct {
|
|
||||||
network string
|
|
||||||
addr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *dohAddr) Network() string { return a.network }
|
|
||||||
func (a *dohAddr) String() string { return a.addr }
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "LoliaFRP-CLI 0.69.1"
|
var version = "0.69.1"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
+15
-62
@@ -28,70 +28,23 @@ var NotFoundPagePath = ""
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
NotFound = `<!DOCTYPE html>
|
NotFound = `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<title>Not Found</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<style>
|
||||||
<title>404 - 未绑定域名</title>
|
body {
|
||||||
<style>
|
width: 35em;
|
||||||
body {
|
margin: 0 auto;
|
||||||
font-family: -apple-system, sans-serif;
|
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
</style>
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
background: #fff;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
line-height: 1.8;
|
|
||||||
color: #666;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
text-align: left;
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #0066cc;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.footer {
|
|
||||||
margin-top: 40px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<h1>The page you requested was not found.</h1>
|
||||||
<h1>域名未绑定</h1>
|
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||||
<p>这个域名还没有绑定到任何隧道哦 (;д;)</p>
|
Please try again later.</p>
|
||||||
<p><strong>可能是这些原因:</strong></p>
|
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
|
||||||
<ul>
|
<p><em>Faithfully yours, frp.</em></p>
|
||||||
<li>域名配置不对,或者没有正确解析</li>
|
|
||||||
<li>隧道可能还没启动,或者已经停止</li>
|
|
||||||
<li>自定义域名忘记在服务端配置了</li>
|
|
||||||
</ul>
|
|
||||||
<div class="footer">由 <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
@@ -116,7 +69,7 @@ func getNotFoundPageContent() []byte {
|
|||||||
|
|
||||||
func NotFoundResponse() *http.Response {
|
func NotFoundResponse() *http.Response {
|
||||||
header := make(http.Header)
|
header := make(http.Header)
|
||||||
header.Set("server", version.Full())
|
header.Set("server", "frp/"+version.Full())
|
||||||
header.Set("Content-Type", "text/html")
|
header.Set("Content-Type", "text/html")
|
||||||
|
|
||||||
content := getNotFoundPageContent()
|
content := getNotFoundPageContent()
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ func (l *Logger) Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Tracef(format string, v ...any) {
|
func (l *Logger) Tracef(format string, v ...any) {
|
||||||
log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...)
|
log.Logger.Tracef(l.prefixString+format, v...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,25 +36,18 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.Handle("/metrics", promhttp.Handler())
|
subRouter.Handle("/metrics", promhttp.Handler())
|
||||||
}
|
}
|
||||||
|
|
||||||
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
|
apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
|
||||||
|
|
||||||
// apis
|
// apis
|
||||||
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
|
|
||||||
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||||
|
|
||||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
|
||||||
|
|
||||||
// view
|
// view
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
|
|||||||
@@ -92,51 +92,6 @@ func (cm *ControlManager) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseAllProxyByName Finds the tunnel name and closes all tunnels on the same connection.
|
|
||||||
func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
|
|
||||||
cm.mu.RLock()
|
|
||||||
var target *Control
|
|
||||||
for _, ctl := range cm.ctlsByRunID {
|
|
||||||
ctl.mu.RLock()
|
|
||||||
_, ok := ctl.proxies[proxyName]
|
|
||||||
ctl.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
target = ctl
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
if target == nil {
|
|
||||||
return fmt.Errorf("no proxy found with name [%s]", proxyName)
|
|
||||||
}
|
|
||||||
return target.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// KickByProxyName finds the Control that manages the given proxy (tunnel) name and closes
|
|
||||||
// Bug: The client does not display the kickout message.
|
|
||||||
func (cm *ControlManager) KickByProxyName(proxyName string) error {
|
|
||||||
cm.mu.RLock()
|
|
||||||
var target *Control
|
|
||||||
for _, ctl := range cm.ctlsByRunID {
|
|
||||||
ctl.mu.RLock()
|
|
||||||
_, ok := ctl.proxies[proxyName]
|
|
||||||
ctl.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
target = ctl
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
|
|
||||||
if target == nil {
|
|
||||||
return fmt.Errorf("no proxy found with name [%s]", proxyName)
|
|
||||||
}
|
|
||||||
|
|
||||||
xl := target.xl
|
|
||||||
xl.Infof("kick client with proxy [%s] by server administrator request", proxyName)
|
|
||||||
return target.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionContext encapsulates the input parameters for creating a new Control.
|
// SessionContext encapsulates the input parameters for creating a new Control.
|
||||||
type SessionContext struct {
|
type SessionContext struct {
|
||||||
// all resource managers and controllers
|
// all resource managers and controllers
|
||||||
|
|||||||
@@ -38,28 +38,21 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,24 +218,6 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
|
|||||||
return proxyInfo, nil
|
return proxyInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/proxy/:name/close
|
|
||||||
func (c *Controller) APICloseProxyByName(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.ctlManager == nil {
|
|
||||||
return nil, fmt.Errorf("control manager unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ctlManager.CloseAllProxyByName(name); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return httppkg.GeneralResponse{Code: 200, Msg: "ok"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/proxies?status=offline
|
// DELETE /api/proxies?status=offline
|
||||||
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||||
status := ctx.Query("status")
|
status := ctx.Query("status")
|
||||||
|
|||||||
@@ -1,394 +0,0 @@
|
|||||||
// Copyright 2026 The frp Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
// Copyright 2026 The frp Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// Copyright 2026 The frp Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package 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"`
|
|
||||||
}
|
|
||||||
+5
-13
@@ -144,26 +144,18 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
name := pxy.GetName()
|
|
||||||
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
|
||||||
rwc = wrapCountingReadWriteCloser(rwc, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficOut(name, proxyType, bytes)
|
|
||||||
}, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficIn(name, proxyType, bytes)
|
|
||||||
})
|
|
||||||
|
|
||||||
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
||||||
workConn = netpkg.WrapCloseNotifyConn(workConn, func(error) {
|
workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)
|
||||||
pxy.updateStatsAfterClosedConn()
|
metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||||
})
|
|
||||||
metrics.Server.OpenConnection(name, proxyType)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *HTTPProxy) updateStatsAfterClosedConn() {
|
func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {
|
||||||
name := pxy.GetName()
|
name := pxy.GetName()
|
||||||
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
proxyType := pxy.GetConfigurer().GetBaseConfig().Type
|
||||||
metrics.Server.CloseConnection(name, proxyType)
|
metrics.Server.CloseConnection(name, proxyType)
|
||||||
|
metrics.Server.AddTrafficIn(name, proxyType, totalWrite)
|
||||||
|
metrics.Server.AddTrafficOut(name, proxyType, totalRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pxy *HTTPProxy) Close() {
|
func (pxy *HTTPProxy) Close() {
|
||||||
|
|||||||
+3
-12
@@ -317,20 +317,11 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
|||||||
|
|
||||||
name := pxy.GetName()
|
name := pxy.GetName()
|
||||||
proxyType := cfg.Type
|
proxyType := cfg.Type
|
||||||
local = wrapCountingReadWriteCloser(local, nil, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficIn(name, proxyType, bytes)
|
|
||||||
})
|
|
||||||
userConn = netpkg.WrapReadWriteCloserToConn(
|
|
||||||
wrapCountingReadWriteCloser(userConn, nil, func(bytes int64) {
|
|
||||||
metrics.Server.AddTrafficOut(name, proxyType, bytes)
|
|
||||||
}),
|
|
||||||
userConn,
|
|
||||||
)
|
|
||||||
metrics.Server.OpenConnection(name, proxyType)
|
metrics.Server.OpenConnection(name, proxyType)
|
||||||
// Traffic is counted incrementally via the counting wrappers above, so the
|
inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
|
||||||
// byte totals returned by joinUserConnection are intentionally discarded here.
|
|
||||||
_, _, _ = pxy.joinUserConnection(local, userConn, proxyType, xl)
|
|
||||||
metrics.Server.CloseConnection(name, proxyType)
|
metrics.Server.CloseConnection(name, proxyType)
|
||||||
|
metrics.Server.AddTrafficIn(name, proxyType, inCount)
|
||||||
|
metrics.Server.AddTrafficOut(name, proxyType, outCount)
|
||||||
xl.Debugf("join connections closed")
|
xl.Debugf("join connections closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import "io"
|
|
||||||
|
|
||||||
type countingReadWriteCloser struct {
|
|
||||||
io.ReadWriteCloser
|
|
||||||
onRead func(int64)
|
|
||||||
onWrite func(int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapCountingReadWriteCloser(rwc io.ReadWriteCloser, onRead, onWrite func(int64)) io.ReadWriteCloser {
|
|
||||||
if onRead == nil && onWrite == nil {
|
|
||||||
return rwc
|
|
||||||
}
|
|
||||||
return &countingReadWriteCloser{
|
|
||||||
ReadWriteCloser: rwc,
|
|
||||||
onRead: onRead,
|
|
||||||
onWrite: onWrite,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *countingReadWriteCloser) Read(p []byte) (n int, err error) {
|
|
||||||
n, err = c.ReadWriteCloser.Read(p)
|
|
||||||
if n > 0 && c.onRead != nil {
|
|
||||||
c.onRead(int64(n))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *countingReadWriteCloser) Write(p []byte) (n int, err error) {
|
|
||||||
n, err = c.ReadWriteCloser.Write(p)
|
|
||||||
if n > 0 && c.onWrite != nil {
|
|
||||||
c.onWrite(int64(n))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,10 @@
|
|||||||
import { buildQueryString, http } from './http'
|
import { http } from './http'
|
||||||
import type { V2Page } from './http'
|
import type { ClientInfoData } from '../types/client'
|
||||||
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
|
||||||
|
|
||||||
export const getClients = () => {
|
export const getClients = () => {
|
||||||
return http.get<ClientInfoData[]>('../api/clients')
|
return http.get<ClientInfoData[]>('../api/clients')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getClientsV2 = (params: ClientListV2Params = {}) => {
|
|
||||||
return http.getV2<V2Page<ClientInfoData>>(
|
|
||||||
`../api/v2/clients${buildQueryString({
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status:
|
|
||||||
params.status && params.status !== 'all' ? params.status : undefined,
|
|
||||||
q: params.q || undefined,
|
|
||||||
user: params.user,
|
|
||||||
clientID: params.clientID || undefined,
|
|
||||||
runID: params.runID || undefined,
|
|
||||||
})}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getClient = (key: string) => {
|
export const getClient = (key: string) => {
|
||||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,6 @@ 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',
|
||||||
@@ -49,55 +34,9 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestV2<T>(
|
|
||||||
url: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const defaultOptions: RequestInit = {
|
|
||||||
credentials: 'include',
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, { ...defaultOptions, ...options })
|
|
||||||
const envelope = (await response.json().catch(() => null)) as
|
|
||||||
| V2Envelope<T>
|
|
||||||
| null
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new HTTPError(
|
|
||||||
response.status,
|
|
||||||
response.statusText,
|
|
||||||
envelope?.msg || `HTTP ${response.status}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!envelope || typeof envelope.code !== 'number') {
|
|
||||||
throw new Error('Invalid API v2 response')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.code >= 400) {
|
|
||||||
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return envelope.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildQueryString = (
|
|
||||||
params: Record<string, QueryParamValue>,
|
|
||||||
): string => {
|
|
||||||
const query = new URLSearchParams()
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (value === null || value === undefined) continue
|
|
||||||
query.append(key, String(value))
|
|
||||||
}
|
|
||||||
const text = query.toString()
|
|
||||||
return text ? `?${text}` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const http = {
|
export const http = {
|
||||||
get: <T>(url: string, options?: RequestInit) =>
|
get: <T>(url: string, options?: RequestInit) =>
|
||||||
request<T>(url, { ...options, method: 'GET' }),
|
request<T>(url, { ...options, method: 'GET' }),
|
||||||
getV2: <T>(url: string, options?: RequestInit) =>
|
|
||||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
|
||||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { buildQueryString, http } from './http'
|
import { 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'
|
||||||
|
|
||||||
@@ -12,40 +9,6 @@ export const getProxiesByType = (type: string) => {
|
|||||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
|
||||||
const page = await http.getV2<V2Page<ProxyV2Info>>(
|
|
||||||
`../api/v2/proxies${buildQueryString({
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status:
|
|
||||||
params.status && params.status !== 'all' ? params.status : undefined,
|
|
||||||
q: params.q || undefined,
|
|
||||||
type: params.type || undefined,
|
|
||||||
user: params.user,
|
|
||||||
clientID: params.clientID || undefined,
|
|
||||||
})}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
items: page.items.map(toLegacyProxyStats),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
|
||||||
name: proxy.name,
|
|
||||||
type: proxy.type,
|
|
||||||
conf: proxy.spec,
|
|
||||||
user: proxy.user,
|
|
||||||
clientID: proxy.clientID,
|
|
||||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
|
||||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
|
||||||
curConns: proxy.status.curConns,
|
|
||||||
lastStartTime: proxy.status.lastStartTime,
|
|
||||||
lastCloseTime: proxy.status.lastCloseTime,
|
|
||||||
status: proxy.status.phase,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getProxy = (type: string, name: string) => {
|
export const getProxy = (type: string, name: string) => {
|
||||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,3 @@ export interface ClientInfoData {
|
|||||||
disconnectedAt?: number
|
disconnectedAt?: number
|
||||||
online: boolean
|
online: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientListV2Params {
|
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
status?: 'all' | 'online' | 'offline'
|
|
||||||
q?: string
|
|
||||||
user?: string
|
|
||||||
clientID?: string
|
|
||||||
runID?: string
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export interface ProxyStatsInfo {
|
export interface ProxyStatsInfo {
|
||||||
name: string
|
name: string
|
||||||
type?: string
|
|
||||||
conf: any
|
conf: any
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
@@ -16,34 +15,6 @@ 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[]
|
||||||
|
|||||||
+46
-112
@@ -16,9 +16,7 @@
|
|||||||
>
|
>
|
||||||
<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 v-if="tab.count !== null" class="tab-count">{{
|
<span class="tab-count">{{ tab.count }}</span>
|
||||||
tab.count
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,9 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="clients-content">
|
<div v-loading="loading" class="clients-content">
|
||||||
<div v-if="clients.length > 0" class="clients-list">
|
<div v-if="filteredClients.length > 0" class="clients-list">
|
||||||
<ClientCard
|
<ClientCard
|
||||||
v-for="client in clients"
|
v-for="client in filteredClients"
|
||||||
:key="client.key"
|
:key="client.key"
|
||||||
:client="client"
|
:client="client"
|
||||||
/>
|
/>
|
||||||
@@ -46,123 +44,82 @@
|
|||||||
<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, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElPagination } from 'element-plus'
|
import { ElMessage } 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 { getClientsV2 } from '../api/client'
|
import { getClients } 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
|
|
||||||
let requestSeq = 0
|
const stats = computed(() => {
|
||||||
|
const total = clients.value.length
|
||||||
|
const online = clients.value.filter((c) => c.online).length
|
||||||
|
const offline = total - online
|
||||||
|
return { total, online, offline }
|
||||||
|
})
|
||||||
|
|
||||||
const statusTabs = computed(() => [
|
const statusTabs = computed(() => [
|
||||||
{
|
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
||||||
value: 'all' as const,
|
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
||||||
label: 'All',
|
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
||||||
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 fetchData = async (silent = false) => {
|
const filteredClients = computed(() => {
|
||||||
const seq = ++requestSeq
|
let result = clients.value
|
||||||
if (!silent) loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await getClientsV2({
|
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
status: statusFilter.value,
|
|
||||||
q: searchText.value.trim(),
|
|
||||||
})
|
|
||||||
if (seq !== requestSeq) return
|
|
||||||
|
|
||||||
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
// Filter by status
|
||||||
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
if (statusFilter.value === 'online') {
|
||||||
page.value = maxPage
|
result = result.filter((c) => c.online)
|
||||||
await fetchData(silent)
|
} else if (statusFilter.value === 'offline') {
|
||||||
return
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
clients.value = data.items.map((item) => new Client(item))
|
return result
|
||||||
total.value = data.total
|
})
|
||||||
page.value = data.page
|
|
||||||
pageSize.value = data.pageSize
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const json = await getClients()
|
||||||
|
clients.value = json.map((data) => new Client(data))
|
||||||
} 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 {
|
||||||
if (seq === requestSeq) {
|
loading.value = false
|
||||||
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(true)
|
fetchData()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,19 +130,6 @@ const stopAutoRefresh = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(statusFilter, () => {
|
|
||||||
resetPageAndFetch()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(searchText, () => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = 1
|
|
||||||
searchDebounceTimer = window.setTimeout(() => {
|
|
||||||
searchDebounceTimer = null
|
|
||||||
fetchData()
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
@@ -193,7 +137,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
clearSearchDebounce()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -331,11 +274,6 @@ 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);
|
||||||
@@ -360,9 +298,5 @@ html.dark .status-tab.active {
|
|||||||
.status-tab {
|
.status-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+122
-166
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-section">
|
<div class="actions-section">
|
||||||
<ActionButton variant="outline" size="small" @click="refreshData">
|
<ActionButton variant="outline" size="small" @click="fetchData">
|
||||||
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="proxies.length > 0" class="proxies-list">
|
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in proxies"
|
v-for="proxy in filteredProxies"
|
||||||
:key="`${proxy.type}:${proxy.name}`"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
:show-type="activeType === 'all'"
|
:show-type="activeType === 'all'"
|
||||||
@@ -87,18 +87,6 @@
|
|||||||
</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"
|
||||||
@@ -111,9 +99,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElPagination } from 'element-plus'
|
import { ElMessage } 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'
|
||||||
@@ -131,13 +119,12 @@ 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 {
|
||||||
getProxiesV2,
|
getProxiesByType,
|
||||||
clearOfflineProxies as apiClearOfflineProxies,
|
clearOfflineProxies as apiClearOfflineProxies,
|
||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import { getClientsV2 } from '../api/client'
|
import { getClients } 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()
|
||||||
@@ -162,12 +149,6 @@ 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
|
||||||
@@ -212,6 +193,58 @@ 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)
|
||||||
@@ -230,174 +263,122 @@ const onClientFilterChange = (key: string) => {
|
|||||||
|
|
||||||
const fetchClients = async () => {
|
const fetchClients = async () => {
|
||||||
try {
|
try {
|
||||||
const allClients: Client[] = []
|
const json = await getClients()
|
||||||
let nextPage = 1
|
clients.value = json.map((data) => new Client(data))
|
||||||
let totalClients = 0
|
} catch {
|
||||||
|
// 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 - cache the Promise itself so concurrent first calls
|
// Server info cache
|
||||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
let serverInfo: {
|
||||||
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 = (): Promise<ServerInfoLite> => {
|
const fetchServerInfo = async () => {
|
||||||
if (!serverInfoPromise) {
|
if (serverInfo) return serverInfo
|
||||||
serverInfoPromise = getServerInfo().catch((err) => {
|
const res = await getServerInfo()
|
||||||
// Allow retry after failure
|
serverInfo = res
|
||||||
serverInfoPromise = null
|
return serverInfo
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return serverInfoPromise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxy = async (
|
const convertProxies = async (
|
||||||
proxy: ProxyStatsInfo,
|
type: string,
|
||||||
): Promise<BaseProxy | null> => {
|
json: any,
|
||||||
const type = proxy.type || activeType.value
|
): Promise<BaseProxy[]> => {
|
||||||
if (type === 'tcp') {
|
if (type === 'tcp') {
|
||||||
return new TCPProxy(proxy)
|
return json.proxies.map((p: any) => new TCPProxy(p))
|
||||||
}
|
}
|
||||||
if (type === 'udp') {
|
if (type === 'udp') {
|
||||||
return new UDPProxy(proxy)
|
return json.proxies.map((p: any) => new UDPProxy(p))
|
||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPPort) {
|
if (info && info.vhostHTTPPort) {
|
||||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
return json.proxies.map(
|
||||||
|
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPSPort) {
|
if (info && info.vhostHTTPSPort) {
|
||||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
return json.proxies.map(
|
||||||
|
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
if (info && info.tcpmuxHTTPConnectPort) {
|
||||||
return new TCPMuxProxy(
|
return json.proxies.map(
|
||||||
proxy,
|
(p: any) =>
|
||||||
info.tcpmuxHTTPConnectPort,
|
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
||||||
info.subdomainHost,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
if (type === 'stcp') {
|
if (type === 'stcp') {
|
||||||
return new STCPProxy(proxy)
|
return json.proxies.map((p: any) => new STCPProxy(p))
|
||||||
}
|
}
|
||||||
if (type === 'sudp') {
|
if (type === 'sudp') {
|
||||||
return new SUDPProxy(proxy)
|
return json.proxies.map((p: any) => new SUDPProxy(p))
|
||||||
}
|
}
|
||||||
// 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.
|
||||||
const bp = new BaseProxy(proxy)
|
return json.proxies.map((p: any) => {
|
||||||
bp.type = type
|
const bp = new BaseProxy(p)
|
||||||
return bp
|
bp.type = type
|
||||||
|
return bp
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
const allProxyTypes = [
|
||||||
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
'tcp',
|
||||||
return converted.filter((item): item is BaseProxy => item !== null)
|
'udp',
|
||||||
}
|
'http',
|
||||||
|
'https',
|
||||||
|
'tcpmux',
|
||||||
|
'stcp',
|
||||||
|
'xtcp',
|
||||||
|
'sudp',
|
||||||
|
]
|
||||||
|
|
||||||
const fetchData = async (silent = false) => {
|
const fetchData = async () => {
|
||||||
const seq = ++requestSeq
|
loading.value = true
|
||||||
if (!silent) loading.value = true
|
proxies.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const q = searchText.value.trim()
|
const type = activeType.value
|
||||||
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
|
|
||||||
|
|
||||||
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
if (type === 'all') {
|
||||||
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
const results = await Promise.all(
|
||||||
page.value = maxPage
|
allProxyTypes.map(async (t) => {
|
||||||
await fetchData(silent)
|
const json = await getProxiesByType(t)
|
||||||
return
|
return convertProxies(t, json)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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 {
|
||||||
if (seq === requestSeq) {
|
loading.value = false
|
||||||
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()
|
||||||
@@ -421,36 +402,20 @@ 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()
|
||||||
@@ -574,11 +539,6 @@ 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;
|
||||||
@@ -587,9 +547,5 @@ fetchClients()
|
|||||||
.client-filter {
|
.client-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Generated
+70
@@ -954,6 +954,76 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nuxt/kit": {
|
||||||
|
"version": "3.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
|
||||||
|
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"c12": "^3.3.3",
|
||||||
|
"consola": "^3.4.2",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"destr": "^2.0.5",
|
||||||
|
"errx": "^0.1.0",
|
||||||
|
"exsolve": "^1.0.8",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"klona": "^2.0.6",
|
||||||
|
"knitwork": "^1.3.0",
|
||||||
|
"mlly": "^1.8.1",
|
||||||
|
"ohash": "^2.0.11",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"pkg-types": "^2.3.0",
|
||||||
|
"rc9": "^3.0.0",
|
||||||
|
"scule": "^1.3.0",
|
||||||
|
"semver": "^7.7.4",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"ufo": "^1.6.3",
|
||||||
|
"unctx": "^2.5.0",
|
||||||
|
"untyped": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nuxt/kit/node_modules/confbox": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@nuxt/kit/node_modules/ignore": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nuxt/kit/node_modules/pkg-types": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user