mirror of
https://github.com/fatedier/frp.git
synced 2026-03-08 10:59:11 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
83847f32ed
|
|||
|
1f2c26761d
|
|||
|
c836e276a7
|
|||
| d16f07d3e8 | |||
|
6bd5eb92c3
|
|||
|
09602f5d74
|
|||
|
d7559b39e2
|
|||
|
72d147dfa5
|
|||
|
481121a6c2
|
|||
|
be252de683
|
|||
|
0a99c1071b
|
|||
|
dd37b2e199
|
|||
|
803e548f42
|
|||
|
2dac44ac2e
|
|||
|
655dc3cb2a
|
|||
|
9894342f46
|
|||
|
e7cc706c86
|
|||
|
92ac2b9153
|
|||
|
1ed369e962
|
|||
|
b74a8d0232
|
|||
|
d2180081a0
|
|||
|
51f4e065b5
|
|||
|
e58f774086
|
|||
|
178e381a26
|
|||
|
26b93ae3a3
|
|||
|
a2aeee28e4
|
|||
|
0416caef71
|
|||
|
1004473e42
|
|||
|
f386996928
|
|||
|
4eb4b202c5
|
|||
|
ac5bdad507
|
|||
|
42f4ea7f87
|
|||
|
36e5ac094b
|
|||
|
72f79d3357
|
|||
|
e1f905f63f
|
|||
|
eb58f09268
|
|||
|
46955ffc80
|
|||
|
2d63296576
|
|||
|
a76ba823ee
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,4 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [fatedier]
|
|
||||||
custom: ["https://afdian.com/a/fatedier"]
|
|
||||||
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
@@ -1,3 +0,0 @@
|
|||||||
### WHY
|
|
||||||
|
|
||||||
<!-- author to complete -->
|
|
||||||
189
.github/workflows/build-all.yaml
vendored
Normal file
189
.github/workflows/build-all.yaml
vendored
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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]
|
||||||
|
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
|
||||||
83
.github/workflows/build-and-push-image.yml
vendored
83
.github/workflows/build-and-push-image.yml
vendored
@@ -1,83 +0,0 @@
|
|||||||
name: Build Image and Publish to Dockerhub & GPR
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [ published ]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Image tag'
|
|
||||||
required: true
|
|
||||||
default: 'test'
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
image:
|
|
||||||
name: Build Image from Dockerfile and binaries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# environment
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: '0'
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
# 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@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to the GPR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
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@v5
|
|
||||||
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@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./dockerfiles/Dockerfile-for-frps
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.TAG_FRPS }}
|
|
||||||
${{ env.TAG_FRPS_GPR }}
|
|
||||||
82
.github/workflows/docker-build.yml
vendored
Normal file
82
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Docker Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: frps
|
||||||
|
dockerfile: dockerfiles/Dockerfile-for-frps
|
||||||
|
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frps
|
||||||
|
- name: frpc
|
||||||
|
dockerfile: dockerfiles/Dockerfile-for-frpc
|
||||||
|
image_name: ghcr.io/${{ github.repository_owner }}/loliacli-frpc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ matrix.image_name }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.dockerfile }}
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Generate image digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
echo "Image pushed to: ${{ matrix.image_name }}"
|
||||||
|
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
||||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -32,4 +32,4 @@ jobs:
|
|||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||||
version: v2.3
|
version: v2.3
|
||||||
129
.github/workflows/release.yaml
vendored
Normal file
129
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: Release FRP Binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag to release (e.g., v1.0.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 调用 build-all workflow
|
||||||
|
build:
|
||||||
|
uses: ./.github/workflows/build-all.yaml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# 创建 release
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get tag name
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Display artifact structure
|
||||||
|
run: |
|
||||||
|
echo "Artifact structure:"
|
||||||
|
ls -R artifacts/
|
||||||
|
|
||||||
|
- name: Organize release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release_files
|
||||||
|
|
||||||
|
# 查找并复制所有压缩包
|
||||||
|
find artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) -exec cp {} release_files/ \;
|
||||||
|
|
||||||
|
# 如果没有压缩包,尝试查找二进制文件并打包
|
||||||
|
if [ -z "$(ls -A release_files/)" ]; then
|
||||||
|
echo "No archives found, looking for directories to package..."
|
||||||
|
for dir in artifacts/*/; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
artifact_name=$(basename "$dir")
|
||||||
|
echo "Packaging $artifact_name"
|
||||||
|
|
||||||
|
# 检查是否是 Windows 构建
|
||||||
|
if echo "$artifact_name" | grep -q "windows"; then
|
||||||
|
(cd "$dir" && zip -r "../../release_files/${artifact_name}.zip" .)
|
||||||
|
else
|
||||||
|
tar -czf "release_files/${artifact_name}.tar.gz" -C "$dir" .
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Files in release_files:"
|
||||||
|
ls -lh release_files/
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd release_files
|
||||||
|
if [ -n "$(ls -A .)" ]; then
|
||||||
|
sha256sum * > sha256sum.txt
|
||||||
|
cat sha256sum.txt
|
||||||
|
else
|
||||||
|
echo "No files to generate checksums for!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Debug - Check tags and commits
|
||||||
|
run: |
|
||||||
|
echo "Current tag: ${{ steps.tag.outputs.tag }}"
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.tag.outputs.tag }}^ 2>/dev/null || echo "none")
|
||||||
|
echo "Previous tag: $PREV_TAG"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Commits between tags:"
|
||||||
|
if [ "$PREV_TAG" != "none" ]; then
|
||||||
|
git log --oneline $PREV_TAG..${{ steps.tag.outputs.tag }}
|
||||||
|
else
|
||||||
|
echo "First tag, showing all commits:"
|
||||||
|
git log --oneline ${{ steps.tag.outputs.tag }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Changelog
|
||||||
|
id: changelog
|
||||||
|
uses: requarks/changelog-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ steps.tag.outputs.tag }}
|
||||||
|
writeToFile: false
|
||||||
|
includeInvalidCommits: true
|
||||||
|
useGitmojis: false
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.tag.outputs.tag }}
|
||||||
|
name: Release ${{ steps.tag.outputs.tag }}
|
||||||
|
body: ${{ steps.changelog.outputs.changes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
release_files/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -7,6 +7,18 @@
|
|||||||
_obj
|
_obj
|
||||||
_test
|
_test
|
||||||
|
|
||||||
|
# Architecture specific extensions/prefixes
|
||||||
|
*.[568vq]
|
||||||
|
[568vq].out
|
||||||
|
|
||||||
|
*.cgo1.go
|
||||||
|
*.cgo2.c
|
||||||
|
_cgo_defun.c
|
||||||
|
_cgo_gotypes.go
|
||||||
|
_cgo_export.*
|
||||||
|
|
||||||
|
_testmain.go
|
||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.prof
|
*.prof
|
||||||
@@ -30,4 +42,6 @@ client.key
|
|||||||
|
|
||||||
# AI
|
# AI
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.sisyphus/
|
|
||||||
|
# TLS
|
||||||
|
.autotls-cache
|
||||||
@@ -38,20 +38,6 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
||||||
|
|
||||||
if svr.storeSource != nil {
|
|
||||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
|
||||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost)
|
|
||||||
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet)
|
|
||||||
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut)
|
|
||||||
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete)
|
|
||||||
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet)
|
|
||||||
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost)
|
|
||||||
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet)
|
|
||||||
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut)
|
|
||||||
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
||||||
@@ -67,14 +53,12 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
|
|
||||||
func newAPIController(svr *Service) *api.Controller {
|
func newAPIController(svr *Service) *api.Controller {
|
||||||
return api.NewController(api.ControllerParams{
|
return api.NewController(api.ControllerParams{
|
||||||
GetProxyStatus: svr.getAllProxyStatus,
|
GetProxyStatus: svr.getAllProxyStatus,
|
||||||
ServerAddr: svr.common.ServerAddr,
|
ServerAddr: svr.common.ServerAddr,
|
||||||
ConfigFilePath: svr.configFilePath,
|
ConfigFilePath: svr.configFilePath,
|
||||||
UnsafeFeatures: svr.unsafeFeatures,
|
UnsafeFeatures: svr.unsafeFeatures,
|
||||||
UpdateConfig: svr.UpdateConfigSource,
|
UpdateConfig: svr.UpdateAllConfigurer,
|
||||||
ReloadFromSources: svr.reloadConfigFromSources,
|
GracefulClose: svr.GracefulClose,
|
||||||
GracefulClose: svr.GracefulClose,
|
|
||||||
StoreSource: svr.storeSource,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -27,7 +26,6 @@ import (
|
|||||||
|
|
||||||
"github.com/fatedier/frp/client/proxy"
|
"github.com/fatedier/frp/client/proxy"
|
||||||
"github.com/fatedier/frp/pkg/config"
|
"github.com/fatedier/frp/pkg/config"
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
@@ -37,48 +35,48 @@ import (
|
|||||||
|
|
||||||
// Controller handles HTTP API requests for frpc.
|
// Controller handles HTTP API requests for frpc.
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
getProxyStatus func() []*proxy.WorkingStatus
|
// getProxyStatus returns the current proxy status.
|
||||||
serverAddr string
|
// Returns nil if the control connection is not established.
|
||||||
configFilePath string
|
getProxyStatus func() []*proxy.WorkingStatus
|
||||||
unsafeFeatures *security.UnsafeFeatures
|
|
||||||
updateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
|
// serverAddr is the frps server address for display.
|
||||||
reloadFromSources func() error
|
serverAddr string
|
||||||
gracefulClose func(d time.Duration)
|
|
||||||
storeSource *source.StoreSource
|
// configFilePath is the path to the configuration file.
|
||||||
|
configFilePath string
|
||||||
|
|
||||||
|
// unsafeFeatures is used for validation.
|
||||||
|
unsafeFeatures *security.UnsafeFeatures
|
||||||
|
|
||||||
|
// updateConfig updates proxy and visitor configurations.
|
||||||
|
updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
|
||||||
|
|
||||||
|
// gracefulClose gracefully stops the service.
|
||||||
|
gracefulClose func(d time.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerParams contains parameters for creating an APIController.
|
// ControllerParams contains parameters for creating an APIController.
|
||||||
type ControllerParams struct {
|
type ControllerParams struct {
|
||||||
GetProxyStatus func() []*proxy.WorkingStatus
|
GetProxyStatus func() []*proxy.WorkingStatus
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
ConfigFilePath string
|
ConfigFilePath string
|
||||||
UnsafeFeatures *security.UnsafeFeatures
|
UnsafeFeatures *security.UnsafeFeatures
|
||||||
UpdateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
|
UpdateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
|
||||||
ReloadFromSources func() error
|
GracefulClose func(d time.Duration)
|
||||||
GracefulClose func(d time.Duration)
|
|
||||||
StoreSource *source.StoreSource
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewController creates a new Controller.
|
||||||
func NewController(params ControllerParams) *Controller {
|
func NewController(params ControllerParams) *Controller {
|
||||||
return &Controller{
|
return &Controller{
|
||||||
getProxyStatus: params.GetProxyStatus,
|
getProxyStatus: params.GetProxyStatus,
|
||||||
serverAddr: params.ServerAddr,
|
serverAddr: params.ServerAddr,
|
||||||
configFilePath: params.ConfigFilePath,
|
configFilePath: params.ConfigFilePath,
|
||||||
unsafeFeatures: params.UnsafeFeatures,
|
unsafeFeatures: params.UnsafeFeatures,
|
||||||
updateConfig: params.UpdateConfig,
|
updateConfig: params.UpdateConfig,
|
||||||
reloadFromSources: params.ReloadFromSources,
|
gracefulClose: params.GracefulClose,
|
||||||
gracefulClose: params.GracefulClose,
|
|
||||||
storeSource: params.StoreSource,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) reloadFromSourcesOrError() error {
|
|
||||||
if err := c.reloadFromSources(); err != nil {
|
|
||||||
return httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to apply config: %v", err))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload handles GET /api/reload
|
// Reload handles GET /api/reload
|
||||||
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
|
||||||
strictConfigMode := false
|
strictConfigMode := false
|
||||||
@@ -87,29 +85,18 @@ func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
|
|||||||
strictConfigMode, _ = strconv.ParseBool(strictStr)
|
strictConfigMode, _ = strconv.ParseBool(strictStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := config.LoadClientConfigResult(c.configFilePath, strictConfigMode)
|
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyCfgs := result.Proxies
|
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil {
|
||||||
visitorCfgs := result.Visitors
|
|
||||||
|
|
||||||
proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(
|
|
||||||
result.Common,
|
|
||||||
proxyCfgs,
|
|
||||||
visitorCfgs,
|
|
||||||
)
|
|
||||||
proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)
|
|
||||||
visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)
|
|
||||||
|
|
||||||
if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, c.unsafeFeatures); err != nil {
|
|
||||||
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.updateConfig(result.Common, proxyCfgs, visitorCfgs); err != nil {
|
if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil {
|
||||||
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
||||||
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@@ -178,6 +165,7 @@ func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
|
||||||
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
|
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
|
||||||
psr := ProxyStatusResp{
|
psr := ProxyStatusResp{
|
||||||
Name: status.Name,
|
Name: status.Name,
|
||||||
@@ -197,302 +185,5 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStat
|
|||||||
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if proxy is from store
|
|
||||||
if c.storeSource != nil {
|
|
||||||
if c.storeSource.GetProxy(status.Name) != nil {
|
|
||||||
psr.Source = "store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return psr
|
return psr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
|
|
||||||
proxies, err := c.storeSource.GetAllProxies()
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list proxies: %v", err))
|
|
||||||
}
|
|
||||||
resp := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))}
|
|
||||||
|
|
||||||
for _, p := range proxies {
|
|
||||||
cfg, err := proxyConfigurerToMap(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Proxies = append(resp.Proxies, ProxyConfig{
|
|
||||||
Name: p.GetBaseConfig().Name,
|
|
||||||
Type: p.GetBaseConfig().Type,
|
|
||||||
Config: cfg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
p := c.storeSource.GetProxy(name)
|
|
||||||
if p == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := proxyConfigurerToMap(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProxyConfig{
|
|
||||||
Name: p.GetBaseConfig().Name,
|
|
||||||
Type: p.GetBaseConfig().Type,
|
|
||||||
Config: cfg,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {
|
|
||||||
body, err := ctx.Body()
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var typed v1.TypedProxyConfig
|
|
||||||
if err := json.Unmarshal(body, &typed); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if typed.ProxyConfigurer == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
typed.Complete()
|
|
||||||
if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.storeSource.AddProxy(typed.ProxyConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusConflict, err.Error())
|
|
||||||
}
|
|
||||||
if err := c.reloadFromSourcesOrError(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("store: created proxy %q", typed.ProxyConfigurer.GetBaseConfig().Name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ctx.Body()
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var typed v1.TypedProxyConfig
|
|
||||||
if err := json.Unmarshal(body, &typed); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if typed.ProxyConfigurer == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyName := typed.ProxyConfigurer.GetBaseConfig().Name
|
|
||||||
if bodyName != name {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name in URL must match name in body")
|
|
||||||
}
|
|
||||||
|
|
||||||
typed.Complete()
|
|
||||||
if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.storeSource.UpdateProxy(typed.ProxyConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
if err := c.reloadFromSourcesOrError(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("store: updated proxy %q", name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.storeSource.RemoveProxy(name); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
if err := c.reloadFromSourcesOrError(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("store: deleted proxy %q", name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
|
|
||||||
visitors, err := c.storeSource.GetAllVisitors()
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list visitors: %v", err))
|
|
||||||
}
|
|
||||||
resp := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))}
|
|
||||||
|
|
||||||
for _, v := range visitors {
|
|
||||||
cfg, err := visitorConfigurerToMap(v)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Visitors = append(resp.Visitors, VisitorConfig{
|
|
||||||
Name: v.GetBaseConfig().Name,
|
|
||||||
Type: v.GetBaseConfig().Type,
|
|
||||||
Config: cfg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
v := c.storeSource.GetVisitor(name)
|
|
||||||
if v == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := visitorConfigurerToMap(v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return VisitorConfig{
|
|
||||||
Name: v.GetBaseConfig().Name,
|
|
||||||
Type: v.GetBaseConfig().Type,
|
|
||||||
Config: cfg,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {
|
|
||||||
body, err := ctx.Body()
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var typed v1.TypedVisitorConfig
|
|
||||||
if err := json.Unmarshal(body, &typed); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if typed.VisitorConfigurer == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
typed.Complete()
|
|
||||||
if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.storeSource.AddVisitor(typed.VisitorConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusConflict, err.Error())
|
|
||||||
}
|
|
||||||
if err := c.reloadFromSourcesOrError(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("store: created visitor %q", typed.VisitorConfigurer.GetBaseConfig().Name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ctx.Body()
|
|
||||||
if err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var typed v1.TypedVisitorConfig
|
|
||||||
if err := json.Unmarshal(body, &typed); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if typed.VisitorConfigurer == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyName := typed.VisitorConfigurer.GetBaseConfig().Name
|
|
||||||
if bodyName != name {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name in URL must match name in body")
|
|
||||||
}
|
|
||||||
|
|
||||||
typed.Complete()
|
|
||||||
if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.storeSource.UpdateVisitor(typed.VisitorConfigurer); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
if err := c.reloadFromSourcesOrError(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("store: updated visitor %q", name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
|
|
||||||
name := ctx.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.storeSource.RemoveVisitor(name); err != nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
if err := c.reloadFromSourcesOrError(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("store: deleted visitor %q", name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func proxyConfigurerToMap(p v1.ProxyConfigurer) (map[string]any, error) {
|
|
||||||
data, err := json.Marshal(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m map[string]any
|
|
||||||
if err := json.Unmarshal(data, &m); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func visitorConfigurerToMap(v v1.VisitorConfigurer) (map[string]any, error) {
|
|
||||||
data, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m map[string]any
|
|
||||||
if err := json.Unmarshal(data, &m); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,34 +26,4 @@ type ProxyStatusResp struct {
|
|||||||
LocalAddr string `json:"local_addr"`
|
LocalAddr string `json:"local_addr"`
|
||||||
Plugin string `json:"plugin"`
|
Plugin string `json:"plugin"`
|
||||||
RemoteAddr string `json:"remote_addr"`
|
RemoteAddr string `json:"remote_addr"`
|
||||||
Source string `json:"source,omitempty"` // "store" or "config"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyConfig wraps proxy configuration for API requests/responses.
|
|
||||||
type ProxyConfig struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Config map[string]any `json:"config"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VisitorConfig wraps visitor configuration for API requests/responses.
|
|
||||||
type VisitorConfig struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Config map[string]any `json:"config"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyListResp is the response for GET /api/store/proxies
|
|
||||||
type ProxyListResp struct {
|
|
||||||
Proxies []ProxyConfig `json:"proxies"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VisitorListResp is the response for GET /api/store/visitors
|
|
||||||
type VisitorListResp struct {
|
|
||||||
Visitors []VisitorConfig `json:"visitors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorResp represents an error response
|
|
||||||
type ErrorResp struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,7 +29,6 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
|
||||||
"github.com/fatedier/frp/pkg/util/wait"
|
"github.com/fatedier/frp/pkg/util/wait"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
@@ -157,8 +158,6 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startMsg.ProxyName = util.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName)
|
|
||||||
|
|
||||||
// dispatch this work connection to related proxy
|
// dispatch this work connection to related proxy
|
||||||
ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg)
|
ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg)
|
||||||
}
|
}
|
||||||
@@ -168,12 +167,46 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
|
|||||||
inMsg := m.(*msg.NewProxyResp)
|
inMsg := m.(*msg.NewProxyResp)
|
||||||
// Server will return NewProxyResp message to each NewProxy message.
|
// Server will return NewProxyResp message to each NewProxy message.
|
||||||
// Start a new proxy handler if no error got
|
// Start a new proxy handler if no error got
|
||||||
proxyName := util.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
|
err := ctl.pm.StartProxy(inMsg.ProxyName, inMsg.RemoteAddr, inMsg.Error)
|
||||||
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("[%s] start error: %v", proxyName, err)
|
xl.Warnf("[%s] 启动失败: %v", inMsg.ProxyName, err)
|
||||||
} else {
|
} else {
|
||||||
xl.Infof("[%s] start proxy success", proxyName)
|
xl.Infof("[%s] 成功启动隧道", inMsg.ProxyName)
|
||||||
|
if inMsg.RemoteAddr != "" {
|
||||||
|
// Get proxy type to format access message
|
||||||
|
if status, ok := ctl.pm.GetProxyStatus(inMsg.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", inMsg.ProxyName, accessMsg)
|
||||||
|
} else {
|
||||||
|
xl.Infof("[%s] 您可通过 %s 访问您的服务", inMsg.ProxyName, inMsg.RemoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ func NewProxy(
|
|||||||
|
|
||||||
baseProxy := BaseProxy{
|
baseProxy := BaseProxy{
|
||||||
baseCfg: pxyConf.GetBaseConfig(),
|
baseCfg: pxyConf.GetBaseConfig(),
|
||||||
|
configurer: pxyConf,
|
||||||
clientCfg: clientCfg,
|
clientCfg: clientCfg,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
@@ -87,6 +90,7 @@ func NewProxy(
|
|||||||
|
|
||||||
type BaseProxy struct {
|
type BaseProxy struct {
|
||||||
baseCfg *v1.ProxyBaseConfig
|
baseCfg *v1.ProxyBaseConfig
|
||||||
|
configurer v1.ProxyConfigurer
|
||||||
clientCfg *v1.ClientCommonConfig
|
clientCfg *v1.ClientCommonConfig
|
||||||
encryptionKey []byte
|
encryptionKey []byte
|
||||||
msgTransporter transport.MessageTransporter
|
msgTransporter transport.MessageTransporter
|
||||||
@@ -106,6 +110,7 @@ func (pxy *BaseProxy) Run() error {
|
|||||||
if pxy.baseCfg.Plugin.Type != "" {
|
if pxy.baseCfg.Plugin.Type != "" {
|
||||||
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
|
||||||
Name: pxy.baseCfg.Name,
|
Name: pxy.baseCfg.Name,
|
||||||
|
HostAllowList: pxy.getPluginHostAllowList(),
|
||||||
VnetController: pxy.vnetController,
|
VnetController: pxy.vnetController,
|
||||||
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
}, pxy.baseCfg.Plugin.ClientPluginOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,6 +121,39 @@ func (pxy *BaseProxy) Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pxy *BaseProxy) getPluginHostAllowList() []string {
|
||||||
|
dedupHosts := make([]string, 0)
|
||||||
|
addHost := func(host string) {
|
||||||
|
host = strings.TrimSpace(strings.ToLower(host))
|
||||||
|
if host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// autocert.HostWhitelist only supports exact host names.
|
||||||
|
if strings.Contains(host, "*") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !slices.Contains(dedupHosts, host) {
|
||||||
|
dedupHosts = append(dedupHosts, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg := pxy.configurer.(type) {
|
||||||
|
case *v1.HTTPProxyConfig:
|
||||||
|
for _, host := range cfg.CustomDomains {
|
||||||
|
addHost(host)
|
||||||
|
}
|
||||||
|
case *v1.HTTPSProxyConfig:
|
||||||
|
for _, host := range cfg.CustomDomains {
|
||||||
|
addHost(host)
|
||||||
|
}
|
||||||
|
case *v1.TCPMuxProxyConfig:
|
||||||
|
for _, host := range cfg.CustomDomains {
|
||||||
|
addHost(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupHosts
|
||||||
|
}
|
||||||
|
|
||||||
func (pxy *BaseProxy) Close() {
|
func (pxy *BaseProxy) Close() {
|
||||||
if pxy.proxyPlugin != nil {
|
if pxy.proxyPlugin != nil {
|
||||||
pxy.proxyPlugin.Close()
|
pxy.proxyPlugin.Close()
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(delPxyNames) > 0 {
|
if len(delPxyNames) > 0 {
|
||||||
xl.Infof("proxy removed: %s", delPxyNames)
|
xl.Infof("隧道移除: %s", delPxyNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
addPxyNames := make([]string, 0)
|
addPxyNames := make([]string, 0)
|
||||||
@@ -177,6 +177,6 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(addPxyNames) > 0 {
|
if len(addPxyNames) > 0 {
|
||||||
xl.Infof("proxy added: %s", addPxyNames)
|
xl.Infof("添加隧道: %s", addPxyNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import (
|
|||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
)
|
)
|
||||||
@@ -87,8 +86,6 @@ type Wrapper struct {
|
|||||||
|
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
wireName string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWrapper(
|
func NewWrapper(
|
||||||
@@ -116,7 +113,6 @@ func NewWrapper(
|
|||||||
vnetController: vnetController,
|
vnetController: vnetController,
|
||||||
xl: xl,
|
xl: xl,
|
||||||
ctx: xlog.NewContext(ctx, xl),
|
ctx: xlog.NewContext(ctx, xl),
|
||||||
wireName: util.AddUserPrefix(clientCfg.User, baseInfo.Name),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 {
|
if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 {
|
||||||
@@ -186,7 +182,7 @@ func (pw *Wrapper) Stop() {
|
|||||||
func (pw *Wrapper) close() {
|
func (pw *Wrapper) close() {
|
||||||
_ = pw.handler(&event.CloseProxyPayload{
|
_ = pw.handler(&event.CloseProxyPayload{
|
||||||
CloseProxyMsg: &msg.CloseProxy{
|
CloseProxyMsg: &msg.CloseProxy{
|
||||||
ProxyName: pw.wireName,
|
ProxyName: pw.Name,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,7 +208,6 @@ func (pw *Wrapper) checkWorker() {
|
|||||||
|
|
||||||
var newProxyMsg msg.NewProxy
|
var newProxyMsg msg.NewProxy
|
||||||
pw.Cfg.MarshalToMsg(&newProxyMsg)
|
pw.Cfg.MarshalToMsg(&newProxyMsg)
|
||||||
newProxyMsg.ProxyName = pw.wireName
|
|
||||||
pw.lastSendStartMsg = now
|
pw.lastSendStartMsg = now
|
||||||
_ = pw.handler(&event.StartProxyPayload{
|
_ = pw.handler(&event.StartProxyPayload{
|
||||||
NewProxyMsg: &newProxyMsg,
|
NewProxyMsg: &newProxyMsg,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func (pxy *UDPProxy) Close() {
|
|||||||
|
|
||||||
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String())
|
xl.Infof("收到一条新的 UDP 代理工作连接, %s", conn.RemoteAddr().String())
|
||||||
// close resources related with old workConn
|
// close resources related with old workConn
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/nathole"
|
"github.com/fatedier/frp/pkg/nathole"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -86,7 +85,7 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
|||||||
transactionID := nathole.NewTransactionID()
|
transactionID := nathole.NewTransactionID()
|
||||||
natHoleClientMsg := &msg.NatHoleClient{
|
natHoleClientMsg := &msg.NatHoleClient{
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
ProxyName: util.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),
|
ProxyName: pxy.cfg.Name,
|
||||||
Sid: natHoleSidMsg.Sid,
|
Sid: natHoleSidMsg.Sid,
|
||||||
MappedAddrs: prepareResult.Addrs,
|
MappedAddrs: prepareResult.Addrs,
|
||||||
AssistedAddrs: prepareResult.AssistedAddrs,
|
AssistedAddrs: prepareResult.AssistedAddrs,
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ import (
|
|||||||
|
|
||||||
"github.com/fatedier/frp/client/proxy"
|
"github.com/fatedier/frp/client/proxy"
|
||||||
"github.com/fatedier/frp/pkg/auth"
|
"github.com/fatedier/frp/pkg/auth"
|
||||||
"github.com/fatedier/frp/pkg/config"
|
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
@@ -63,11 +61,9 @@ func (e cancelErr) Error() string {
|
|||||||
|
|
||||||
// ServiceOptions contains options for creating a new client service.
|
// ServiceOptions contains options for creating a new client service.
|
||||||
type ServiceOptions struct {
|
type ServiceOptions struct {
|
||||||
Common *v1.ClientCommonConfig
|
Common *v1.ClientCommonConfig
|
||||||
|
ProxyCfgs []v1.ProxyConfigurer
|
||||||
// ConfigSourceAggregator manages internal config and optional store sources.
|
VisitorCfgs []v1.VisitorConfigurer
|
||||||
// It is required for creating a Service.
|
|
||||||
ConfigSourceAggregator *source.Aggregator
|
|
||||||
|
|
||||||
UnsafeFeatures *security.UnsafeFeatures
|
UnsafeFeatures *security.UnsafeFeatures
|
||||||
|
|
||||||
@@ -123,20 +119,11 @@ type Service struct {
|
|||||||
|
|
||||||
vnetController *vnet.Controller
|
vnetController *vnet.Controller
|
||||||
|
|
||||||
cfgMu sync.RWMutex
|
cfgMu sync.RWMutex
|
||||||
common *v1.ClientCommonConfig
|
common *v1.ClientCommonConfig
|
||||||
// reloadCommon is used for filtering/defaulting during config-source reloads.
|
proxyCfgs []v1.ProxyConfigurer
|
||||||
// It can be updated by /api/reload without mutating startup-only common behavior.
|
visitorCfgs []v1.VisitorConfigurer
|
||||||
reloadCommon *v1.ClientCommonConfig
|
clientSpec *msg.ClientSpec
|
||||||
proxyCfgs []v1.ProxyConfigurer
|
|
||||||
visitorCfgs []v1.VisitorConfigurer
|
|
||||||
clientSpec *msg.ClientSpec
|
|
||||||
|
|
||||||
// aggregator manages multiple configuration sources.
|
|
||||||
// When set, the service watches for config changes and reloads automatically.
|
|
||||||
aggregator *source.Aggregator
|
|
||||||
configSource *source.ConfigSource
|
|
||||||
storeSource *source.StoreSource
|
|
||||||
|
|
||||||
unsafeFeatures *security.UnsafeFeatures
|
unsafeFeatures *security.UnsafeFeatures
|
||||||
|
|
||||||
@@ -173,39 +160,19 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.ConfigSourceAggregator == nil {
|
|
||||||
return nil, fmt.Errorf("config source aggregator is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
configSource := options.ConfigSourceAggregator.ConfigSource()
|
|
||||||
storeSource := options.ConfigSourceAggregator.StoreSource()
|
|
||||||
|
|
||||||
proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load()
|
|
||||||
if loadErr != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr)
|
|
||||||
}
|
|
||||||
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs)
|
|
||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
|
||||||
|
|
||||||
s := &Service{
|
s := &Service{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
auth: authRuntime,
|
auth: authRuntime,
|
||||||
webServer: webServer,
|
webServer: webServer,
|
||||||
common: options.Common,
|
common: options.Common,
|
||||||
reloadCommon: options.Common,
|
|
||||||
configFilePath: options.ConfigFilePath,
|
configFilePath: options.ConfigFilePath,
|
||||||
unsafeFeatures: options.UnsafeFeatures,
|
unsafeFeatures: options.UnsafeFeatures,
|
||||||
proxyCfgs: proxyCfgs,
|
proxyCfgs: options.ProxyCfgs,
|
||||||
visitorCfgs: visitorCfgs,
|
visitorCfgs: options.VisitorCfgs,
|
||||||
clientSpec: options.ClientSpec,
|
clientSpec: options.ClientSpec,
|
||||||
aggregator: options.ConfigSourceAggregator,
|
|
||||||
configSource: configSource,
|
|
||||||
storeSource: storeSource,
|
|
||||||
connectorCreator: options.ConnectorCreator,
|
connectorCreator: options.ConnectorCreator,
|
||||||
handleWorkConnCb: options.HandleWorkConnCb,
|
handleWorkConnCb: options.HandleWorkConnCb,
|
||||||
}
|
}
|
||||||
|
|
||||||
if webServer != nil {
|
if webServer != nil {
|
||||||
webServer.RouteRegister(s.registerRouteHandlers)
|
webServer.RouteRegister(s.registerRouteHandlers)
|
||||||
}
|
}
|
||||||
@@ -252,7 +219,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
if svr.ctl == nil {
|
if svr.ctl == nil {
|
||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go svr.keepControllerWorking()
|
go svr.keepControllerWorking()
|
||||||
@@ -357,7 +324,7 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
|
|||||||
svr.runID = loginRespMsg.RunID
|
svr.runID = loginRespMsg.RunID
|
||||||
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
||||||
|
|
||||||
xl.Infof("login to server success, get run id [%s]", loginRespMsg.RunID)
|
xl.Infof("登录服务器成功, 获取 run id [%s]", loginRespMsg.RunID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,10 +332,10 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
xl := xlog.FromContextSafe(svr.ctx)
|
xl := xlog.FromContextSafe(svr.ctx)
|
||||||
|
|
||||||
loginFunc := func() (bool, error) {
|
loginFunc := func() (bool, error) {
|
||||||
xl.Infof("try to connect to server...")
|
xl.Infof("尝试连接到服务器...")
|
||||||
conn, connector, err := svr.login()
|
conn, connector, err := svr.login()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("connect to server error: %v", err)
|
xl.Warnf("连接服务器错误: %v", err)
|
||||||
if firstLoginExit {
|
if firstLoginExit {
|
||||||
svr.cancel(cancelErr{Err: err})
|
svr.cancel(cancelErr{Err: err})
|
||||||
}
|
}
|
||||||
@@ -436,33 +403,6 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) UpdateConfigSource(
|
|
||||||
common *v1.ClientCommonConfig,
|
|
||||||
proxyCfgs []v1.ProxyConfigurer,
|
|
||||||
visitorCfgs []v1.VisitorConfigurer,
|
|
||||||
) error {
|
|
||||||
cfgSource := svr.configSource
|
|
||||||
if cfgSource == nil {
|
|
||||||
return fmt.Errorf("config source is not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update reloadCommon before ReplaceAll so the subsequent reload uses the
|
|
||||||
// same common config as /api/reload validation.
|
|
||||||
svr.cfgMu.Lock()
|
|
||||||
prevReloadCommon := svr.reloadCommon
|
|
||||||
svr.reloadCommon = common
|
|
||||||
svr.cfgMu.Unlock()
|
|
||||||
|
|
||||||
if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
|
||||||
svr.cfgMu.Lock()
|
|
||||||
svr.reloadCommon = prevReloadCommon
|
|
||||||
svr.cfgMu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return svr.reloadConfigFromSources()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svr *Service) Close() {
|
func (svr *Service) Close() {
|
||||||
svr.GracefulClose(time.Duration(0))
|
svr.GracefulClose(time.Duration(0))
|
||||||
}
|
}
|
||||||
@@ -483,11 +423,6 @@ func (svr *Service) stop() {
|
|||||||
svr.webServer.Close()
|
svr.webServer.Close()
|
||||||
svr.webServer = nil
|
svr.webServer = nil
|
||||||
}
|
}
|
||||||
if svr.aggregator != nil {
|
|
||||||
svr.aggregator = nil
|
|
||||||
}
|
|
||||||
svr.configSource = nil
|
|
||||||
svr.storeSource = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
||||||
@@ -518,28 +453,3 @@ type statusExporterImpl struct {
|
|||||||
func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
||||||
return s.getProxyStatusFunc(name)
|
return s.getProxyStatusFunc(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) reloadConfigFromSources() error {
|
|
||||||
if svr.aggregator == nil {
|
|
||||||
return errors.New("config aggregator is not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
svr.cfgMu.RLock()
|
|
||||||
reloadCommon := svr.reloadCommon
|
|
||||||
svr.cfgMu.RUnlock()
|
|
||||||
|
|
||||||
proxies, visitors, err := svr.aggregator.Load()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reload config from sources failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
|
|
||||||
proxies = config.CompleteProxyConfigurers(proxies)
|
|
||||||
visitors = config.CompleteVisitorConfigurers(visitors)
|
|
||||||
|
|
||||||
// Atomically replace the entire configuration
|
|
||||||
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,10 +103,9 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
|
|||||||
defer visitorConn.Close()
|
defer visitorConn.Close()
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
|
||||||
newVisitorConnMsg := &msg.NewVisitorConn{
|
newVisitorConnMsg := &msg.NewVisitorConn{
|
||||||
RunID: sv.helper.RunID(),
|
RunID: sv.helper.RunID(),
|
||||||
ProxyName: targetProxyName,
|
ProxyName: sv.cfg.ServerName,
|
||||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
UseEncryption: sv.cfg.Transport.UseEncryption,
|
UseEncryption: sv.cfg.Transport.UseEncryption,
|
||||||
|
|||||||
@@ -205,10 +205,9 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
|
||||||
newVisitorConnMsg := &msg.NewVisitorConn{
|
newVisitorConnMsg := &msg.NewVisitorConn{
|
||||||
RunID: sv.helper.RunID(),
|
RunID: sv.helper.RunID(),
|
||||||
ProxyName: targetProxyName,
|
ProxyName: sv.cfg.ServerName,
|
||||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
UseEncryption: sv.cfg.Transport.UseEncryption,
|
UseEncryption: sv.cfg.Transport.UseEncryption,
|
||||||
|
|||||||
@@ -280,9 +280,8 @@ func (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) {
|
|||||||
// 4. Create a tunnel session using an underlying UDP connection.
|
// 4. Create a tunnel session using an underlying UDP connection.
|
||||||
func (sv *XTCPVisitor) makeNatHole() {
|
func (sv *XTCPVisitor) makeNatHole() {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
xl := xlog.FromContextSafe(sv.ctx)
|
||||||
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
|
||||||
xl.Tracef("makeNatHole start")
|
xl.Tracef("makeNatHole start")
|
||||||
if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), targetProxyName, 5*time.Second); err != nil {
|
if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), sv.cfg.ServerName, 5*time.Second); err != nil {
|
||||||
xl.Warnf("nathole precheck error: %v", err)
|
xl.Warnf("nathole precheck error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -311,7 +310,7 @@ func (sv *XTCPVisitor) makeNatHole() {
|
|||||||
transactionID := nathole.NewTransactionID()
|
transactionID := nathole.NewTransactionID()
|
||||||
natHoleVisitorMsg := &msg.NatHoleVisitor{
|
natHoleVisitorMsg := &msg.NatHoleVisitor{
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
ProxyName: targetProxyName,
|
ProxyName: sv.cfg.ServerName,
|
||||||
Protocol: sv.cfg.Protocol,
|
Protocol: sv.cfg.Protocol,
|
||||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er
|
|||||||
Use: name,
|
Use: name,
|
||||||
Short: short,
|
Short: short,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||||
|
fmt.Println("frpc: the configuration file is not specified")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -48,8 +48,17 @@ 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 pameters
|
// ignore error here, because we can use command line pameters
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
var cfg *v1.ClientCommonConfig
|
||||||
if err != nil {
|
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
|
||||||
|
_, _, _, _, 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 = &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)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config"
|
"github.com/fatedier/frp/pkg/config"
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
@@ -87,14 +86,13 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Complete(clientCfg.User)
|
||||||
c.GetBaseConfig().Type = name
|
c.GetBaseConfig().Type = name
|
||||||
c.Complete()
|
if err := validation.ValidateProxyConfigurerForClient(c); err != nil {
|
||||||
proxyCfg := c
|
|
||||||
if err := validation.ValidateProxyConfigurerForClient(proxyCfg); err != nil {
|
|
||||||
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{c}, nil, unsafeFeatures, "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -119,14 +117,13 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Complete(clientCfg)
|
||||||
c.GetBaseConfig().Type = name
|
c.GetBaseConfig().Type = name
|
||||||
c.Complete()
|
if err := validation.ValidateVisitorConfigurer(c); err != nil {
|
||||||
visitorCfg := c
|
|
||||||
if err := validation.ValidateVisitorConfigurer(visitorCfg); err != nil {
|
|
||||||
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{c}, unsafeFeatures, "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -134,18 +131,3 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startService(
|
|
||||||
cfg *v1.ClientCommonConfig,
|
|
||||||
proxyCfgs []v1.ProxyConfigurer,
|
|
||||||
visitorCfgs []v1.VisitorConfigurer,
|
|
||||||
unsafeFeatures *security.UnsafeFeatures,
|
|
||||||
cfgFile string,
|
|
||||||
) error {
|
|
||||||
configSource := source.NewConfigSource()
|
|
||||||
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
|
|
||||||
return fmt.Errorf("failed to set config source: %w", err)
|
|
||||||
}
|
|
||||||
aggregator := source.NewAggregator(configSource)
|
|
||||||
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ package sub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -30,29 +33,32 @@ import (
|
|||||||
|
|
||||||
"github.com/fatedier/frp/client"
|
"github.com/fatedier/frp/client"
|
||||||
"github.com/fatedier/frp/pkg/config"
|
"github.com/fatedier/frp/pkg/config"
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
"github.com/fatedier/frp/pkg/policy/featuregate"
|
"github.com/fatedier/frp/pkg/policy/featuregate"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
|
"github.com/fatedier/frp/pkg/util/banner"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFile string
|
cfgFiles []string
|
||||||
cfgDir string
|
cfgDir string
|
||||||
showVersion bool
|
showVersion bool
|
||||||
strictConfigMode bool
|
strictConfigMode bool
|
||||||
allowUnsafe []string
|
allowUnsafe []string
|
||||||
|
authTokens []string
|
||||||
|
|
||||||
|
bannerDisplayed bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
|
rootCmd.PersistentFlags().StringSliceVarP(&cfgFiles, "config", "c", []string{"./frpc.ini"}, "config files of frpc (support multiple files)")
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
|
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
|
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
|
||||||
|
rootCmd.PersistentFlags().StringSliceVarP(&authTokens, "token", "t", []string{}, "authentication tokens in format 'id:token' (LoliaFRP only)")
|
||||||
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
|
||||||
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
|
||||||
}
|
}
|
||||||
@@ -68,15 +74,30 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
|
||||||
|
|
||||||
|
// If authTokens is provided, fetch config from API
|
||||||
|
if len(authTokens) > 0 {
|
||||||
|
err := runClientWithTokens(authTokens, unsafeFeatures)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
|
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
|
||||||
// Note that it's only designed for testing. It's not guaranteed to be stable.
|
|
||||||
if cfgDir != "" {
|
if cfgDir != "" {
|
||||||
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
_ = runMultipleClients(cfgDir, unsafeFeatures)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If multiple config files are specified, run one frpc service for each file
|
||||||
|
if len(cfgFiles) > 1 {
|
||||||
|
runMultipleClientsFromFiles(cfgFiles, unsafeFeatures)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Do not show command usage here.
|
// Do not show command usage here.
|
||||||
err := runClient(cfgFile, unsafeFeatures)
|
err := runClient(cfgFiles[0], unsafeFeatures)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -106,6 +127,29 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runMultipleClientsFromFiles(cfgFiles []string, unsafeFeatures *security.UnsafeFeatures) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Display banner first
|
||||||
|
banner.DisplayBanner()
|
||||||
|
bannerDisplayed = true
|
||||||
|
log.Infof("检测到 %d 个配置文件,将启动多个 frpc 服务实例", len(cfgFiles))
|
||||||
|
|
||||||
|
for _, cfgFile := range cfgFiles {
|
||||||
|
wg.Add(1)
|
||||||
|
// Add a small delay to avoid log output mixing
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
go func(path string) {
|
||||||
|
defer wg.Done()
|
||||||
|
err := runClient(path, unsafeFeatures)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("\n配置文件 [%s] 启动失败: %v\n", path, err)
|
||||||
|
}
|
||||||
|
}(cfgFile)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
@@ -121,64 +165,22 @@ func handleTermSignal(svr *client.Service) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {
|
func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {
|
||||||
// Load configuration
|
cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
|
||||||
result, err := config.LoadClientConfigResult(cfgFilePath, strictConfigMode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result.IsLegacyFormat {
|
if isLegacyFormat {
|
||||||
fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " +
|
fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " +
|
||||||
"please use yaml/json/toml format instead!\n")
|
"please use yaml/json/toml format instead!\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Common.FeatureGates) > 0 {
|
if len(cfg.FeatureGates) > 0 {
|
||||||
if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {
|
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
|
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
||||||
}
|
|
||||||
|
|
||||||
// runClientWithAggregator runs the client using the internal source aggregator.
|
|
||||||
func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatures *security.UnsafeFeatures, cfgFilePath string) error {
|
|
||||||
configSource := source.NewConfigSource()
|
|
||||||
if err := configSource.ReplaceAll(result.Proxies, result.Visitors); err != nil {
|
|
||||||
return fmt.Errorf("failed to set config source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var storeSource *source.StoreSource
|
|
||||||
|
|
||||||
if result.Common.Store.IsEnabled() {
|
|
||||||
storePath := result.Common.Store.Path
|
|
||||||
if storePath != "" && cfgFilePath != "" && !filepath.IsAbs(storePath) {
|
|
||||||
storePath = filepath.Join(filepath.Dir(cfgFilePath), storePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := source.NewStoreSource(source.StoreSourceConfig{
|
|
||||||
Path: storePath,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create store source: %w", err)
|
|
||||||
}
|
|
||||||
storeSource = s
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregator := source.NewAggregator(configSource)
|
|
||||||
if storeSource != nil {
|
|
||||||
aggregator.SetStoreSource(storeSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyCfgs, visitorCfgs, err := aggregator.Load()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load config from sources: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)
|
|
||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
|
||||||
|
|
||||||
warning, err := validation.ValidateAllClientConfig(result.Common, proxyCfgs, visitorCfgs, unsafeFeatures)
|
|
||||||
if warning != nil {
|
if warning != nil {
|
||||||
fmt.Printf("WARNING: %v\n", warning)
|
fmt.Printf("WARNING: %v\n", warning)
|
||||||
}
|
}
|
||||||
@@ -186,34 +188,234 @@ func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatur
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
|
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServiceWithAggregator(
|
func startService(
|
||||||
cfg *v1.ClientCommonConfig,
|
cfg *v1.ClientCommonConfig,
|
||||||
aggregator *source.Aggregator,
|
proxyCfgs []v1.ProxyConfigurer,
|
||||||
|
visitorCfgs []v1.VisitorConfigurer,
|
||||||
unsafeFeatures *security.UnsafeFeatures,
|
unsafeFeatures *security.UnsafeFeatures,
|
||||||
cfgFile string,
|
cfgFile string,
|
||||||
|
nodeName string,
|
||||||
|
tunnelRemark string,
|
||||||
) error {
|
) error {
|
||||||
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
||||||
|
|
||||||
|
// Display banner only once before starting the first service
|
||||||
|
if !bannerDisplayed {
|
||||||
|
banner.DisplayBanner()
|
||||||
|
bannerDisplayed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display node information if available
|
||||||
|
if nodeName != "" {
|
||||||
|
log.Info("已获取到配置文件", "隧道名称", tunnelRemark, "使用节点", nodeName)
|
||||||
|
}
|
||||||
|
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
|
log.Infof("启动 frpc 服务 [%s]", cfgFile)
|
||||||
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
|
defer log.Infof("frpc 服务 [%s] 已停止", cfgFile)
|
||||||
}
|
}
|
||||||
svr, err := client.NewService(client.ServiceOptions{
|
svr, err := client.NewService(client.ServiceOptions{
|
||||||
Common: cfg,
|
Common: cfg,
|
||||||
ConfigSourceAggregator: aggregator,
|
ProxyCfgs: proxyCfgs,
|
||||||
UnsafeFeatures: unsafeFeatures,
|
VisitorCfgs: visitorCfgs,
|
||||||
ConfigFilePath: cfgFile,
|
UnsafeFeatures: unsafeFeatures,
|
||||||
|
ConfigFilePath: cfgFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic"
|
shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic"
|
||||||
|
// Capture the exit signal if we use kcp or quic.
|
||||||
if shouldGracefulClose {
|
if shouldGracefulClose {
|
||||||
go handleTermSignal(svr)
|
go handleTermSignal(svr)
|
||||||
}
|
}
|
||||||
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, ","))
|
||||||
|
// #nosec G107 -- URL is constructed from trusted source (environment variable or hardcoded)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Complete for all proxies to add name prefix (e.g., user.tunnel_name)
|
||||||
|
for _, c := range proxyCfgs {
|
||||||
|
c.Complete(cfg.User)
|
||||||
|
}
|
||||||
|
for _, c := range visitorCfgs {
|
||||||
|
c.Complete(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.FeatureGates) > 0 {
|
||||||
|
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
|
||||||
|
if warning != nil {
|
||||||
|
fmt.Printf("WARNING: %v\n", warning)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, "", nodeName, tunnelRemark)
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ var verifyCmd = &cobra.Command{
|
|||||||
Use: "verify",
|
Use: "verify",
|
||||||
Short: "Verify that the configures is valid",
|
Short: "Verify that the configures is valid",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if cfgFile == "" {
|
if len(cfgFiles) == 0 || cfgFiles[0] == "" {
|
||||||
fmt.Println("frpc: the configuration file is not specified")
|
fmt.Println("frpc: the configuration file is not specified")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfgFile := cfgFiles[0]
|
||||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
|
|||||||
@@ -329,6 +329,14 @@ type = "https2http"
|
|||||||
localAddr = "127.0.0.1:80"
|
localAddr = "127.0.0.1:80"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
keyPath = "./server.key"
|
||||||
|
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||||
|
# [proxies.plugin.autoTLS]
|
||||||
|
# enable = true
|
||||||
|
# email = "admin@example.com"
|
||||||
|
# cacheDir = "./.autotls-cache"
|
||||||
|
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||||
|
# hostAllowList = ["test.yourdomain.com"]
|
||||||
|
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
hostHeaderRewrite = "127.0.0.1"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
@@ -341,6 +349,14 @@ type = "https2https"
|
|||||||
localAddr = "127.0.0.1:443"
|
localAddr = "127.0.0.1:443"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
keyPath = "./server.key"
|
||||||
|
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||||
|
# [proxies.plugin.autoTLS]
|
||||||
|
# enable = true
|
||||||
|
# email = "admin@example.com"
|
||||||
|
# cacheDir = "./.autotls-cache"
|
||||||
|
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||||
|
# hostAllowList = ["test.yourdomain.com"]
|
||||||
|
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
hostHeaderRewrite = "127.0.0.1"
|
hostHeaderRewrite = "127.0.0.1"
|
||||||
requestHeaders.set.x-from-where = "frp"
|
requestHeaders.set.x-from-where = "frp"
|
||||||
|
|
||||||
@@ -373,6 +389,14 @@ type = "tls2raw"
|
|||||||
localAddr = "127.0.0.1:80"
|
localAddr = "127.0.0.1:80"
|
||||||
crtPath = "./server.crt"
|
crtPath = "./server.crt"
|
||||||
keyPath = "./server.key"
|
keyPath = "./server.key"
|
||||||
|
# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates.
|
||||||
|
# [proxies.plugin.autoTLS]
|
||||||
|
# enable = true
|
||||||
|
# email = "admin@example.com"
|
||||||
|
# cacheDir = "./.autotls-cache"
|
||||||
|
# hostAllowList is optional. If omitted, frpc will use customDomains automatically.
|
||||||
|
# hostAllowList = ["test.yourdomain.com"]
|
||||||
|
# caDirURL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "secret_tcp"
|
name = "secret_tcp"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ COPY web/frpc/ ./
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.24 AS building
|
FROM golang:1.25 AS building
|
||||||
|
|
||||||
COPY . /building
|
COPY . /building
|
||||||
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ COPY web/frps/ ./
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.24 AS building
|
FROM golang:1.25 AS building
|
||||||
|
|
||||||
COPY . /building
|
COPY . /building
|
||||||
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
||||||
|
|||||||
15
go.mod
15
go.mod
@@ -39,10 +39,18 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // 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/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/log v0.4.2 // 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
|
||||||
@@ -51,6 +59,10 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.7 // indirect
|
github.com/pion/dtls/v2 v2.2.7 // indirect
|
||||||
github.com/pion/logging v0.2.2 // indirect
|
github.com/pion/logging v0.2.2 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.1 // indirect
|
github.com/pion/transport/v2 v2.2.1 // indirect
|
||||||
@@ -60,13 +72,16 @@ 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
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
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/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.27.0 // indirect
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
|||||||
31
go.sum
31
go.sum
@@ -4,11 +4,25 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzS
|
|||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
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=
|
||||||
@@ -26,6 +40,8 @@ github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMB
|
|||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
@@ -70,8 +86,16 @@ 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/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 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
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=
|
||||||
@@ -109,6 +133,8 @@ github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9M
|
|||||||
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 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=
|
||||||
@@ -149,6 +175,8 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
|||||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
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=
|
||||||
@@ -167,6 +195,8 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
|
|||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
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=
|
||||||
@@ -209,6 +239,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.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=
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ rm -rf ./release/packages
|
|||||||
mkdir -p ./release/packages
|
mkdir -p ./release/packages
|
||||||
|
|
||||||
os_all='linux windows darwin freebsd openbsd android'
|
os_all='linux windows darwin freebsd openbsd android'
|
||||||
arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
|
arch_all='amd64 arm arm64'
|
||||||
extra_all='_ hf'
|
extra_all='_ hf'
|
||||||
|
|
||||||
cd ./release
|
cd ./release
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.
|
|||||||
}
|
}
|
||||||
|
|
||||||
configurer.UnmarshalFromMsg(m)
|
configurer.UnmarshalFromMsg(m)
|
||||||
configurer.Complete()
|
configurer.Complete("")
|
||||||
|
|
||||||
if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil {
|
if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -219,131 +219,60 @@ func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error)
|
|||||||
return svrCfg, isLegacyFormat, nil
|
return svrCfg, isLegacyFormat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientConfigLoadResult contains the result of loading a client configuration file.
|
|
||||||
type ClientConfigLoadResult struct {
|
|
||||||
// Common contains the common client configuration.
|
|
||||||
Common *v1.ClientCommonConfig
|
|
||||||
|
|
||||||
// Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles.
|
|
||||||
// These are NOT completed (user prefix not added).
|
|
||||||
Proxies []v1.ProxyConfigurer
|
|
||||||
|
|
||||||
// Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles.
|
|
||||||
// These are NOT completed.
|
|
||||||
Visitors []v1.VisitorConfigurer
|
|
||||||
|
|
||||||
// IsLegacyFormat indicates whether the config file is in legacy INI format.
|
|
||||||
IsLegacyFormat bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadClientConfigResult loads and parses a client configuration file.
|
|
||||||
// It returns the raw configuration without completing proxies/visitors.
|
|
||||||
// The caller should call Complete on the configs manually for legacy behavior.
|
|
||||||
func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) {
|
|
||||||
result := &ClientConfigLoadResult{
|
|
||||||
Proxies: make([]v1.ProxyConfigurer, 0),
|
|
||||||
Visitors: make([]v1.VisitorConfigurer, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
if DetectLegacyINIFormatFromFile(path) {
|
|
||||||
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
|
|
||||||
for _, c := range legacyProxyCfgs {
|
|
||||||
result.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c))
|
|
||||||
}
|
|
||||||
for _, c := range legacyVisitorCfgs {
|
|
||||||
result.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c))
|
|
||||||
}
|
|
||||||
result.IsLegacyFormat = true
|
|
||||||
} else {
|
|
||||||
allCfg := v1.ClientConfig{}
|
|
||||||
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.Common = &allCfg.ClientCommonConfig
|
|
||||||
for _, c := range allCfg.Proxies {
|
|
||||||
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
|
|
||||||
}
|
|
||||||
for _, c := range allCfg.Visitors {
|
|
||||||
result.Visitors = append(result.Visitors, c.VisitorConfigurer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load additional config from includes.
|
|
||||||
// legacy ini format already handle this in ParseClientConfig.
|
|
||||||
if len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat {
|
|
||||||
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.Proxies = append(result.Proxies, extProxyCfgs...)
|
|
||||||
result.Visitors = append(result.Visitors, extVisitorCfgs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete the common config
|
|
||||||
if result.Common != nil {
|
|
||||||
if err := result.Common.Complete(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadClientConfig(path string, strict bool) (
|
func LoadClientConfig(path string, strict bool) (
|
||||||
*v1.ClientCommonConfig,
|
*v1.ClientCommonConfig,
|
||||||
[]v1.ProxyConfigurer,
|
[]v1.ProxyConfigurer,
|
||||||
[]v1.VisitorConfigurer,
|
[]v1.VisitorConfigurer,
|
||||||
bool, error,
|
bool, error,
|
||||||
) {
|
) {
|
||||||
result, err := LoadClientConfigResult(path, strict)
|
var (
|
||||||
if err != nil {
|
cliCfg *v1.ClientCommonConfig
|
||||||
return nil, nil, nil, result != nil && result.IsLegacyFormat, err
|
proxyCfgs = make([]v1.ProxyConfigurer, 0)
|
||||||
|
visitorCfgs = make([]v1.VisitorConfigurer, 0)
|
||||||
|
isLegacyFormat bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if DetectLegacyINIFormatFromFile(path) {
|
||||||
|
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, true, err
|
||||||
|
}
|
||||||
|
cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
|
||||||
|
for _, c := range legacyProxyCfgs {
|
||||||
|
proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c))
|
||||||
|
}
|
||||||
|
for _, c := range legacyVisitorCfgs {
|
||||||
|
visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c))
|
||||||
|
}
|
||||||
|
isLegacyFormat = true
|
||||||
|
} else {
|
||||||
|
allCfg := v1.ClientConfig{}
|
||||||
|
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
|
||||||
|
return nil, nil, nil, false, err
|
||||||
|
}
|
||||||
|
cliCfg = &allCfg.ClientCommonConfig
|
||||||
|
for _, c := range allCfg.Proxies {
|
||||||
|
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
|
||||||
|
}
|
||||||
|
for _, c := range allCfg.Visitors {
|
||||||
|
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyCfgs := result.Proxies
|
// Load additional config from includes.
|
||||||
visitorCfgs := result.Visitors
|
// legacy ini format already handle this in ParseClientConfig.
|
||||||
|
if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat {
|
||||||
proxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)
|
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict)
|
||||||
proxyCfgs = CompleteProxyConfigurers(proxyCfgs)
|
if err != nil {
|
||||||
visitorCfgs = CompleteVisitorConfigurers(visitorCfgs)
|
return nil, nil, nil, isLegacyFormat, err
|
||||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
}
|
||||||
}
|
proxyCfgs = append(proxyCfgs, extProxyCfgs...)
|
||||||
|
visitorCfgs = append(visitorCfgs, extVisitorCfgs...)
|
||||||
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
|
||||||
proxyCfgs := proxies
|
|
||||||
for _, c := range proxyCfgs {
|
|
||||||
c.Complete()
|
|
||||||
}
|
}
|
||||||
return proxyCfgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer {
|
|
||||||
visitorCfgs := visitors
|
|
||||||
for _, c := range visitorCfgs {
|
|
||||||
c.Complete()
|
|
||||||
}
|
|
||||||
return visitorCfgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func FilterClientConfigurers(
|
|
||||||
common *v1.ClientCommonConfig,
|
|
||||||
proxies []v1.ProxyConfigurer,
|
|
||||||
visitors []v1.VisitorConfigurer,
|
|
||||||
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
|
|
||||||
if common == nil {
|
|
||||||
common = &v1.ClientCommonConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyCfgs := proxies
|
|
||||||
visitorCfgs := visitors
|
|
||||||
|
|
||||||
// Filter by start
|
// Filter by start
|
||||||
if len(common.Start) > 0 {
|
if len(cliCfg.Start) > 0 {
|
||||||
startSet := sets.New(common.Start...)
|
startSet := sets.New(cliCfg.Start...)
|
||||||
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
|
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
|
||||||
return startSet.Has(c.GetBaseConfig().Name)
|
return startSet.Has(c.GetBaseConfig().Name)
|
||||||
})
|
})
|
||||||
@@ -362,7 +291,19 @@ func FilterClientConfigurers(
|
|||||||
enabled := c.GetBaseConfig().Enabled
|
enabled := c.GetBaseConfig().Enabled
|
||||||
return enabled == nil || *enabled
|
return enabled == nil || *enabled
|
||||||
})
|
})
|
||||||
return proxyCfgs, visitorCfgs
|
|
||||||
|
if cliCfg != nil {
|
||||||
|
if err := cliCfg.Complete(); err != nil {
|
||||||
|
return nil, nil, nil, isLegacyFormat, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range proxyCfgs {
|
||||||
|
c.Complete(cliCfg.User)
|
||||||
|
}
|
||||||
|
for _, c := range visitorCfgs {
|
||||||
|
c.Complete(cliCfg)
|
||||||
|
}
|
||||||
|
return cliCfg, proxyCfgs, visitorCfgs, isLegacyFormat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
|
func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -274,169 +273,6 @@ proxies:
|
|||||||
require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type)
|
require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilterClientConfigurers_PreserveRawNamesAndNoMutation(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
enabled := true
|
|
||||||
proxyCfg := &v1.TCPProxyConfig{}
|
|
||||||
proxyCfg.Name = "proxy-raw"
|
|
||||||
proxyCfg.Type = "tcp"
|
|
||||||
proxyCfg.LocalPort = 10080
|
|
||||||
proxyCfg.Enabled = &enabled
|
|
||||||
|
|
||||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
|
||||||
visitorCfg.Name = "visitor-raw"
|
|
||||||
visitorCfg.Type = "xtcp"
|
|
||||||
visitorCfg.ServerName = "server-raw"
|
|
||||||
visitorCfg.FallbackTo = "fallback-raw"
|
|
||||||
visitorCfg.SecretKey = "secret"
|
|
||||||
visitorCfg.BindPort = 10081
|
|
||||||
visitorCfg.Enabled = &enabled
|
|
||||||
|
|
||||||
common := &v1.ClientCommonConfig{
|
|
||||||
User: "alice",
|
|
||||||
}
|
|
||||||
|
|
||||||
proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
require.Len(visitors, 1)
|
|
||||||
|
|
||||||
p := proxies[0].GetBaseConfig()
|
|
||||||
require.Equal("proxy-raw", p.Name)
|
|
||||||
require.Empty(p.LocalIP)
|
|
||||||
|
|
||||||
v := visitors[0].GetBaseConfig()
|
|
||||||
require.Equal("visitor-raw", v.Name)
|
|
||||||
require.Equal("server-raw", v.ServerName)
|
|
||||||
require.Empty(v.BindAddr)
|
|
||||||
|
|
||||||
xtcp := visitors[0].(*v1.XTCPVisitorConfig)
|
|
||||||
require.Equal("fallback-raw", xtcp.FallbackTo)
|
|
||||||
require.Empty(xtcp.Protocol)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteProxyConfigurers_PreserveRawNames(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
enabled := true
|
|
||||||
proxyCfg := &v1.TCPProxyConfig{}
|
|
||||||
proxyCfg.Name = "proxy-raw"
|
|
||||||
proxyCfg.Type = "tcp"
|
|
||||||
proxyCfg.LocalPort = 10080
|
|
||||||
proxyCfg.Enabled = &enabled
|
|
||||||
|
|
||||||
proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
|
|
||||||
p := proxies[0].GetBaseConfig()
|
|
||||||
require.Equal("proxy-raw", p.Name)
|
|
||||||
require.Equal("127.0.0.1", p.LocalIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteVisitorConfigurers_PreserveRawNames(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
enabled := true
|
|
||||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
|
||||||
visitorCfg.Name = "visitor-raw"
|
|
||||||
visitorCfg.Type = "xtcp"
|
|
||||||
visitorCfg.ServerName = "server-raw"
|
|
||||||
visitorCfg.FallbackTo = "fallback-raw"
|
|
||||||
visitorCfg.SecretKey = "secret"
|
|
||||||
visitorCfg.BindPort = 10081
|
|
||||||
visitorCfg.Enabled = &enabled
|
|
||||||
|
|
||||||
visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})
|
|
||||||
require.Len(visitors, 1)
|
|
||||||
|
|
||||||
v := visitors[0].GetBaseConfig()
|
|
||||||
require.Equal("visitor-raw", v.Name)
|
|
||||||
require.Equal("server-raw", v.ServerName)
|
|
||||||
require.Equal("127.0.0.1", v.BindAddr)
|
|
||||||
|
|
||||||
xtcp := visitors[0].(*v1.XTCPVisitorConfig)
|
|
||||||
require.Equal("fallback-raw", xtcp.FallbackTo)
|
|
||||||
require.Equal("quic", xtcp.Protocol)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteProxyConfigurers_Idempotent(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
proxyCfg := &v1.TCPProxyConfig{}
|
|
||||||
proxyCfg.Name = "proxy"
|
|
||||||
proxyCfg.Type = "tcp"
|
|
||||||
proxyCfg.LocalPort = 10080
|
|
||||||
|
|
||||||
proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})
|
|
||||||
firstProxyJSON, err := json.Marshal(proxies[0])
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies = CompleteProxyConfigurers(proxies)
|
|
||||||
secondProxyJSON, err := json.Marshal(proxies[0])
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
require.Equal(string(firstProxyJSON), string(secondProxyJSON))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteVisitorConfigurers_Idempotent(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
|
||||||
visitorCfg.Name = "visitor"
|
|
||||||
visitorCfg.Type = "xtcp"
|
|
||||||
visitorCfg.ServerName = "server"
|
|
||||||
visitorCfg.SecretKey = "secret"
|
|
||||||
visitorCfg.BindPort = 10081
|
|
||||||
|
|
||||||
visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})
|
|
||||||
firstVisitorJSON, err := json.Marshal(visitors[0])
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
visitors = CompleteVisitorConfigurers(visitors)
|
|
||||||
secondVisitorJSON, err := json.Marshal(visitors[0])
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
require.Equal(string(firstVisitorJSON), string(secondVisitorJSON))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
enabled := true
|
|
||||||
disabled := false
|
|
||||||
|
|
||||||
proxyKeep := &v1.TCPProxyConfig{}
|
|
||||||
proxyKeep.Name = "keep"
|
|
||||||
proxyKeep.Type = "tcp"
|
|
||||||
proxyKeep.LocalPort = 10080
|
|
||||||
proxyKeep.Enabled = &enabled
|
|
||||||
|
|
||||||
proxyDropByStart := &v1.TCPProxyConfig{}
|
|
||||||
proxyDropByStart.Name = "drop-by-start"
|
|
||||||
proxyDropByStart.Type = "tcp"
|
|
||||||
proxyDropByStart.LocalPort = 10081
|
|
||||||
proxyDropByStart.Enabled = &enabled
|
|
||||||
|
|
||||||
proxyDropByEnabled := &v1.TCPProxyConfig{}
|
|
||||||
proxyDropByEnabled.Name = "drop-by-enabled"
|
|
||||||
proxyDropByEnabled.Type = "tcp"
|
|
||||||
proxyDropByEnabled.LocalPort = 10082
|
|
||||||
proxyDropByEnabled.Enabled = &disabled
|
|
||||||
|
|
||||||
common := &v1.ClientCommonConfig{
|
|
||||||
Start: []string{"keep"},
|
|
||||||
}
|
|
||||||
|
|
||||||
proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{
|
|
||||||
proxyKeep,
|
|
||||||
proxyDropByStart,
|
|
||||||
proxyDropByEnabled,
|
|
||||||
}, nil)
|
|
||||||
require.Len(visitors, 0)
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||||
func TestYAMLEdgeCases(t *testing.T) {
|
func TestYAMLEdgeCases(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|||||||
@@ -1,125 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
type sourceEntry struct {
|
|
||||||
source Source
|
|
||||||
}
|
|
||||||
|
|
||||||
type Aggregator struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
|
|
||||||
configSource *ConfigSource
|
|
||||||
storeSource *StoreSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAggregator(configSource *ConfigSource) *Aggregator {
|
|
||||||
if configSource == nil {
|
|
||||||
configSource = NewConfigSource()
|
|
||||||
}
|
|
||||||
return &Aggregator{
|
|
||||||
configSource: configSource,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Aggregator) SetStoreSource(storeSource *StoreSource) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
a.storeSource = storeSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Aggregator) ConfigSource() *ConfigSource {
|
|
||||||
return a.configSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Aggregator) StoreSource() *StoreSource {
|
|
||||||
return a.storeSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Aggregator) getSourcesLocked() []sourceEntry {
|
|
||||||
sources := make([]sourceEntry, 0, 2)
|
|
||||||
if a.configSource != nil {
|
|
||||||
sources = append(sources, sourceEntry{
|
|
||||||
source: a.configSource,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if a.storeSource != nil {
|
|
||||||
sources = append(sources, sourceEntry{
|
|
||||||
source: a.storeSource,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
|
|
||||||
a.mu.RLock()
|
|
||||||
entries := a.getSourcesLocked()
|
|
||||||
a.mu.RUnlock()
|
|
||||||
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return nil, nil, errors.New("no sources configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyMap := make(map[string]v1.ProxyConfigurer)
|
|
||||||
visitorMap := make(map[string]v1.VisitorConfigurer)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
proxies, visitors, err := entry.source.Load()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("load source: %w", err)
|
|
||||||
}
|
|
||||||
for _, p := range proxies {
|
|
||||||
proxyMap[p.GetBaseConfig().Name] = p
|
|
||||||
}
|
|
||||||
for _, v := range visitors {
|
|
||||||
visitorMap[v.GetBaseConfig().Name] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
proxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap)
|
|
||||||
return proxies, visitors, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Aggregator) mapsToSortedSlices(
|
|
||||||
proxyMap map[string]v1.ProxyConfigurer,
|
|
||||||
visitorMap map[string]v1.VisitorConfigurer,
|
|
||||||
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
|
|
||||||
proxies := make([]v1.ProxyConfigurer, 0, len(proxyMap))
|
|
||||||
for _, p := range proxyMap {
|
|
||||||
proxies = append(proxies, p)
|
|
||||||
}
|
|
||||||
sort.Slice(proxies, func(i, j int) bool {
|
|
||||||
return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name
|
|
||||||
})
|
|
||||||
|
|
||||||
visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap))
|
|
||||||
for _, v := range visitorMap {
|
|
||||||
visitors = append(visitors, v)
|
|
||||||
}
|
|
||||||
sort.Slice(visitors, func(i, j int) bool {
|
|
||||||
return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name
|
|
||||||
})
|
|
||||||
|
|
||||||
return proxies, visitors
|
|
||||||
}
|
|
||||||
@@ -1,217 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockProxy creates a TCP proxy config for testing
|
|
||||||
func mockProxy(name string) v1.ProxyConfigurer {
|
|
||||||
cfg := &v1.TCPProxyConfig{}
|
|
||||||
cfg.Name = name
|
|
||||||
cfg.Type = "tcp"
|
|
||||||
cfg.LocalPort = 8080
|
|
||||||
cfg.RemotePort = 9090
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockVisitor creates a STCP visitor config for testing
|
|
||||||
func mockVisitor(name string) v1.VisitorConfigurer {
|
|
||||||
cfg := &v1.STCPVisitorConfig{}
|
|
||||||
cfg.Name = name
|
|
||||||
cfg.Type = "stcp"
|
|
||||||
cfg.ServerName = "test-server"
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestStoreSource(t *testing.T) *StoreSource {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "store.json")
|
|
||||||
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
|
|
||||||
require.NoError(t, err)
|
|
||||||
return storeSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
configSource := NewConfigSource()
|
|
||||||
agg := NewAggregator(configSource)
|
|
||||||
if storeSource != nil {
|
|
||||||
agg.SetStoreSource(storeSource)
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
agg := NewAggregator(nil)
|
|
||||||
require.NotNil(agg)
|
|
||||||
require.NotNil(agg.ConfigSource())
|
|
||||||
require.Nil(agg.StoreSource())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewAggregator_WithoutStore(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
configSource := NewConfigSource()
|
|
||||||
agg := NewAggregator(configSource)
|
|
||||||
require.NotNil(agg)
|
|
||||||
require.Same(configSource, agg.ConfigSource())
|
|
||||||
require.Nil(agg.StoreSource())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewAggregator_WithStore(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
storeSource := newTestStoreSource(t)
|
|
||||||
configSource := NewConfigSource()
|
|
||||||
agg := NewAggregator(configSource)
|
|
||||||
agg.SetStoreSource(storeSource)
|
|
||||||
|
|
||||||
require.Same(configSource, agg.ConfigSource())
|
|
||||||
require.Same(storeSource, agg.StoreSource())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregator_SetStoreSource_Overwrite(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
agg := newTestAggregator(t, nil)
|
|
||||||
first := newTestStoreSource(t)
|
|
||||||
second := newTestStoreSource(t)
|
|
||||||
|
|
||||||
agg.SetStoreSource(first)
|
|
||||||
require.Same(first, agg.StoreSource())
|
|
||||||
|
|
||||||
agg.SetStoreSource(second)
|
|
||||||
require.Same(second, agg.StoreSource())
|
|
||||||
|
|
||||||
agg.SetStoreSource(nil)
|
|
||||||
require.Nil(agg.StoreSource())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregator_MergeBySourceOrder(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
storeSource := newTestStoreSource(t)
|
|
||||||
agg := newTestAggregator(t, storeSource)
|
|
||||||
|
|
||||||
configSource := agg.ConfigSource()
|
|
||||||
|
|
||||||
configShared := mockProxy("shared").(*v1.TCPProxyConfig)
|
|
||||||
configShared.LocalPort = 1111
|
|
||||||
configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig)
|
|
||||||
configOnly.LocalPort = 1112
|
|
||||||
|
|
||||||
err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
storeShared := mockProxy("shared").(*v1.TCPProxyConfig)
|
|
||||||
storeShared.LocalPort = 2222
|
|
||||||
storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig)
|
|
||||||
storeOnly.LocalPort = 2223
|
|
||||||
err = storeSource.AddProxy(storeShared)
|
|
||||||
require.NoError(err)
|
|
||||||
err = storeSource.AddProxy(storeOnly)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, visitors, err := agg.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(visitors, 0)
|
|
||||||
require.Len(proxies, 3)
|
|
||||||
|
|
||||||
var sharedProxy *v1.TCPProxyConfig
|
|
||||||
for _, p := range proxies {
|
|
||||||
if p.GetBaseConfig().Name == "shared" {
|
|
||||||
sharedProxy = p.(*v1.TCPProxyConfig)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.NotNil(sharedProxy)
|
|
||||||
require.Equal(2222, sharedProxy.LocalPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
storeSource := newTestStoreSource(t)
|
|
||||||
agg := newTestAggregator(t, storeSource)
|
|
||||||
configSource := agg.ConfigSource()
|
|
||||||
|
|
||||||
lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
|
|
||||||
lowProxy.LocalPort = 1111
|
|
||||||
err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
disabled := false
|
|
||||||
highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
|
|
||||||
highProxy.LocalPort = 2222
|
|
||||||
highProxy.Enabled = &disabled
|
|
||||||
err = storeSource.AddProxy(highProxy)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, visitors, err := agg.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
require.Len(visitors, 0)
|
|
||||||
|
|
||||||
proxy := proxies[0].(*v1.TCPProxyConfig)
|
|
||||||
require.Equal("shared-proxy", proxy.Name)
|
|
||||||
require.Equal(1111, proxy.LocalPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregator_VisitorMerge(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
storeSource := newTestStoreSource(t)
|
|
||||||
agg := newTestAggregator(t, storeSource)
|
|
||||||
|
|
||||||
err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")})
|
|
||||||
require.NoError(err)
|
|
||||||
err = storeSource.AddVisitor(mockVisitor("visitor2"))
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
_, visitors, err := agg.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(visitors, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregator_Load_ReturnsSharedReferences(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
agg := newTestAggregator(t, nil)
|
|
||||||
err := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy("ssh")}, nil)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, _, err := agg.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
require.Equal("ssh", proxies[0].GetBaseConfig().Name)
|
|
||||||
|
|
||||||
proxies[0].GetBaseConfig().Name = "alice.ssh"
|
|
||||||
|
|
||||||
proxies2, _, err := agg.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies2, 1)
|
|
||||||
require.Equal("alice.ssh", proxies2[0].GetBaseConfig().Name)
|
|
||||||
}
|
|
||||||
@@ -1,65 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// baseSource provides shared state and behavior for Source implementations.
|
|
||||||
// It manages proxy/visitor storage.
|
|
||||||
// Concrete types (ConfigSource, StoreSource) embed this struct.
|
|
||||||
type baseSource struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
|
|
||||||
proxies map[string]v1.ProxyConfigurer
|
|
||||||
visitors map[string]v1.VisitorConfigurer
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBaseSource() baseSource {
|
|
||||||
return baseSource{
|
|
||||||
proxies: make(map[string]v1.ProxyConfigurer),
|
|
||||||
visitors: make(map[string]v1.VisitorConfigurer),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load returns all enabled proxy and visitor configurations.
|
|
||||||
// Configurations with Enabled explicitly set to false are filtered out.
|
|
||||||
func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies))
|
|
||||||
for _, p := range s.proxies {
|
|
||||||
// Filter out disabled proxies (nil or true means enabled)
|
|
||||||
if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
proxies = append(proxies, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors))
|
|
||||||
for _, v := range s.visitors {
|
|
||||||
// Filter out disabled visitors (nil or true means enabled)
|
|
||||||
if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
visitors = append(visitors, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return proxies, visitors, nil
|
|
||||||
}
|
|
||||||
@@ -1,65 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigSource implements Source for in-memory configuration.
|
|
||||||
// All operations are thread-safe.
|
|
||||||
type ConfigSource struct {
|
|
||||||
baseSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConfigSource() *ConfigSource {
|
|
||||||
return &ConfigSource{
|
|
||||||
baseSource: newBaseSource(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplaceAll replaces all proxy and visitor configurations atomically.
|
|
||||||
func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
|
|
||||||
for _, p := range proxies {
|
|
||||||
if p == nil {
|
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
|
||||||
}
|
|
||||||
name := p.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
nextProxies[name] = p
|
|
||||||
}
|
|
||||||
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
|
|
||||||
for _, v := range visitors {
|
|
||||||
if v == nil {
|
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
|
||||||
}
|
|
||||||
name := v.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
nextVisitors[name] = v
|
|
||||||
}
|
|
||||||
s.proxies = nextProxies
|
|
||||||
s.visitors = nextVisitors
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,173 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewConfigSource(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
src := NewConfigSource()
|
|
||||||
require.NotNil(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigSource_ReplaceAll(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
src := NewConfigSource()
|
|
||||||
|
|
||||||
err := src.ReplaceAll(
|
|
||||||
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
|
|
||||||
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, visitors, err := src.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 2)
|
|
||||||
require.Len(visitors, 1)
|
|
||||||
|
|
||||||
// ReplaceAll again should replace everything
|
|
||||||
err = src.ReplaceAll(
|
|
||||||
[]v1.ProxyConfigurer{mockProxy("proxy3")},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, visitors, err = src.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
require.Len(visitors, 0)
|
|
||||||
require.Equal("proxy3", proxies[0].GetBaseConfig().Name)
|
|
||||||
|
|
||||||
// ReplaceAll with nil proxy should fail
|
|
||||||
err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil)
|
|
||||||
require.Error(err)
|
|
||||||
|
|
||||||
// ReplaceAll with empty name proxy should fail
|
|
||||||
err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil)
|
|
||||||
require.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigSource_Load(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
src := NewConfigSource()
|
|
||||||
|
|
||||||
err := src.ReplaceAll(
|
|
||||||
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
|
|
||||||
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, visitors, err := src.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 2)
|
|
||||||
require.Len(visitors, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out
|
|
||||||
// proxies and visitors with Enabled explicitly set to false.
|
|
||||||
func TestConfigSource_Load_FiltersDisabled(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
src := NewConfigSource()
|
|
||||||
|
|
||||||
disabled := false
|
|
||||||
enabled := true
|
|
||||||
|
|
||||||
// Create enabled proxy (nil Enabled = enabled by default)
|
|
||||||
enabledProxy := mockProxy("enabled-proxy")
|
|
||||||
|
|
||||||
// Create disabled proxy
|
|
||||||
disabledProxy := &v1.TCPProxyConfig{}
|
|
||||||
disabledProxy.Name = "disabled-proxy"
|
|
||||||
disabledProxy.Type = "tcp"
|
|
||||||
disabledProxy.Enabled = &disabled
|
|
||||||
|
|
||||||
// Create explicitly enabled proxy
|
|
||||||
explicitEnabledProxy := &v1.TCPProxyConfig{}
|
|
||||||
explicitEnabledProxy.Name = "explicit-enabled-proxy"
|
|
||||||
explicitEnabledProxy.Type = "tcp"
|
|
||||||
explicitEnabledProxy.Enabled = &enabled
|
|
||||||
|
|
||||||
// Create enabled visitor (nil Enabled = enabled by default)
|
|
||||||
enabledVisitor := mockVisitor("enabled-visitor")
|
|
||||||
|
|
||||||
// Create disabled visitor
|
|
||||||
disabledVisitor := &v1.STCPVisitorConfig{}
|
|
||||||
disabledVisitor.Name = "disabled-visitor"
|
|
||||||
disabledVisitor.Type = "stcp"
|
|
||||||
disabledVisitor.Enabled = &disabled
|
|
||||||
|
|
||||||
err := src.ReplaceAll(
|
|
||||||
[]v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy},
|
|
||||||
[]v1.VisitorConfigurer{enabledVisitor, disabledVisitor},
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
// Load should filter out disabled configs
|
|
||||||
proxies, visitors, err := src.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 2, "Should have 2 enabled proxies")
|
|
||||||
require.Len(visitors, 1, "Should have 1 enabled visitor")
|
|
||||||
|
|
||||||
// Verify the correct proxies are returned
|
|
||||||
proxyNames := make([]string, 0, len(proxies))
|
|
||||||
for _, p := range proxies {
|
|
||||||
proxyNames = append(proxyNames, p.GetBaseConfig().Name)
|
|
||||||
}
|
|
||||||
require.Contains(proxyNames, "enabled-proxy")
|
|
||||||
require.Contains(proxyNames, "explicit-enabled-proxy")
|
|
||||||
require.NotContains(proxyNames, "disabled-proxy")
|
|
||||||
|
|
||||||
// Verify the correct visitor is returned
|
|
||||||
require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
src := NewConfigSource()
|
|
||||||
|
|
||||||
proxyCfg := &v1.TCPProxyConfig{}
|
|
||||||
proxyCfg.Name = "proxy1"
|
|
||||||
proxyCfg.Type = "tcp"
|
|
||||||
proxyCfg.LocalPort = 10080
|
|
||||||
|
|
||||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
|
||||||
visitorCfg.Name = "visitor1"
|
|
||||||
visitorCfg.Type = "xtcp"
|
|
||||||
visitorCfg.ServerName = "server1"
|
|
||||||
visitorCfg.SecretKey = "secret"
|
|
||||||
visitorCfg.BindPort = 10081
|
|
||||||
|
|
||||||
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxies, visitors, err := src.Load()
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(proxies, 1)
|
|
||||||
require.Len(visitors, 1)
|
|
||||||
require.Empty(proxies[0].GetBaseConfig().LocalIP)
|
|
||||||
require.Empty(visitors[0].GetBaseConfig().BindAddr)
|
|
||||||
require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol)
|
|
||||||
}
|
|
||||||
@@ -1,37 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Source is the interface for configuration sources.
|
|
||||||
// A Source provides proxy and visitor configurations from various backends.
|
|
||||||
// Aggregator currently uses the built-in config source as base and an optional
|
|
||||||
// store source as higher-priority overlay.
|
|
||||||
type Source interface {
|
|
||||||
// Load loads the proxy and visitor configurations from this source.
|
|
||||||
// Returns the loaded configurations and any error encountered.
|
|
||||||
// A disabled entry in one source is source-local filtering, not a cross-source
|
|
||||||
// tombstone for entries from lower-priority sources.
|
|
||||||
//
|
|
||||||
// Error handling contract with Aggregator:
|
|
||||||
// - When err is nil, returned slices are consumed.
|
|
||||||
// - When err is non-nil, Aggregator aborts the merge and returns the error.
|
|
||||||
// - To publish best-effort or partial results, return those results with
|
|
||||||
// err set to nil.
|
|
||||||
Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error)
|
|
||||||
}
|
|
||||||
@@ -1,351 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StoreSourceConfig struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type storeData struct {
|
|
||||||
Proxies []v1.TypedProxyConfig `json:"proxies,omitempty"`
|
|
||||||
Visitors []v1.TypedVisitorConfig `json:"visitors,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StoreSource struct {
|
|
||||||
baseSource
|
|
||||||
config StoreSourceConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
|
|
||||||
if cfg.Path == "" {
|
|
||||||
return nil, fmt.Errorf("path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &StoreSource{
|
|
||||||
baseSource: newBaseSource(),
|
|
||||||
config: cfg,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.loadFromFile(); err != nil {
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
return nil, fmt.Errorf("failed to load existing data: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) loadFromFile() error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.loadFromFileUnlocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) loadFromFileUnlocked() error {
|
|
||||||
data, err := os.ReadFile(s.config.Path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var stored storeData
|
|
||||||
if err := json.Unmarshal(data, &stored); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.proxies = make(map[string]v1.ProxyConfigurer)
|
|
||||||
s.visitors = make(map[string]v1.VisitorConfigurer)
|
|
||||||
|
|
||||||
for _, tp := range stored.Proxies {
|
|
||||||
if tp.ProxyConfigurer != nil {
|
|
||||||
proxyCfg := tp.ProxyConfigurer
|
|
||||||
name := proxyCfg.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
s.proxies[name] = proxyCfg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tv := range stored.Visitors {
|
|
||||||
if tv.VisitorConfigurer != nil {
|
|
||||||
visitorCfg := tv.VisitorConfigurer
|
|
||||||
name := visitorCfg.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
s.visitors[name] = visitorCfg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) saveToFileUnlocked() error {
|
|
||||||
stored := storeData{
|
|
||||||
Proxies: make([]v1.TypedProxyConfig, 0, len(s.proxies)),
|
|
||||||
Visitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range s.proxies {
|
|
||||||
stored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p})
|
|
||||||
}
|
|
||||||
for _, v := range s.visitors {
|
|
||||||
stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(stored, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(s.config.Path)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := s.config.Path + ".tmp"
|
|
||||||
|
|
||||||
f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := f.Write(data); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.Sync(); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("failed to sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpPath, s.config.Path); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
|
|
||||||
if proxy == nil {
|
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := proxy.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := s.proxies[name]; exists {
|
|
||||||
return fmt.Errorf("proxy %q already exists", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.proxies[name] = proxy
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
delete(s.proxies, name)
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
|
|
||||||
if proxy == nil {
|
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := proxy.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldProxy, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("proxy %q does not exist", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.proxies[name] = proxy
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.proxies[name] = oldProxy
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) RemoveProxy(name string) error {
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldProxy, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("proxy %q does not exist", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.proxies, name)
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.proxies[name] = oldProxy
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
p, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
|
|
||||||
if visitor == nil {
|
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := visitor.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := s.visitors[name]; exists {
|
|
||||||
return fmt.Errorf("visitor %q already exists", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.visitors[name] = visitor
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
delete(s.visitors, name)
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
|
|
||||||
if visitor == nil {
|
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := visitor.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldVisitor, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("visitor %q does not exist", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.visitors[name] = visitor
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.visitors[name] = oldVisitor
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) RemoveVisitor(name string) error {
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldVisitor, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("visitor %q does not exist", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.visitors, name)
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.visitors[name] = oldVisitor
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
v, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
result := make([]v1.ProxyConfigurer, 0, len(s.proxies))
|
|
||||||
for _, p := range s.proxies {
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
result := make([]v1.VisitorConfigurer, 0, len(s.visitors))
|
|
||||||
for _, v := range s.visitors {
|
|
||||||
result = append(result, v)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,99 +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 source
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "store.json")
|
|
||||||
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
proxyCfg := &v1.TCPProxyConfig{}
|
|
||||||
proxyCfg.Name = "proxy1"
|
|
||||||
proxyCfg.Type = "tcp"
|
|
||||||
proxyCfg.LocalPort = 10080
|
|
||||||
|
|
||||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
|
||||||
visitorCfg.Name = "visitor1"
|
|
||||||
visitorCfg.Type = "xtcp"
|
|
||||||
visitorCfg.ServerName = "server1"
|
|
||||||
visitorCfg.SecretKey = "secret"
|
|
||||||
visitorCfg.BindPort = 10081
|
|
||||||
|
|
||||||
err = storeSource.AddProxy(proxyCfg)
|
|
||||||
require.NoError(err)
|
|
||||||
err = storeSource.AddVisitor(visitorCfg)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
gotProxy := storeSource.GetProxy("proxy1")
|
|
||||||
require.NotNil(gotProxy)
|
|
||||||
require.Empty(gotProxy.GetBaseConfig().LocalIP)
|
|
||||||
|
|
||||||
gotVisitor := storeSource.GetVisitor("visitor1")
|
|
||||||
require.NotNil(gotVisitor)
|
|
||||||
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
|
|
||||||
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "store.json")
|
|
||||||
|
|
||||||
proxyCfg := &v1.TCPProxyConfig{}
|
|
||||||
proxyCfg.Name = "proxy1"
|
|
||||||
proxyCfg.Type = "tcp"
|
|
||||||
proxyCfg.LocalPort = 10080
|
|
||||||
|
|
||||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
|
||||||
visitorCfg.Name = "visitor1"
|
|
||||||
visitorCfg.Type = "xtcp"
|
|
||||||
visitorCfg.ServerName = "server1"
|
|
||||||
visitorCfg.SecretKey = "secret"
|
|
||||||
visitorCfg.BindPort = 10081
|
|
||||||
|
|
||||||
stored := storeData{
|
|
||||||
Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},
|
|
||||||
Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(stored)
|
|
||||||
require.NoError(err)
|
|
||||||
err = os.WriteFile(path, data, 0o600)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
gotProxy := storeSource.GetProxy("proxy1")
|
|
||||||
require.NotNil(gotProxy)
|
|
||||||
require.Empty(gotProxy.GetBaseConfig().LocalIP)
|
|
||||||
|
|
||||||
gotVisitor := storeSource.GetVisitor("visitor1")
|
|
||||||
require.NotNil(gotVisitor)
|
|
||||||
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
|
|
||||||
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
|
|
||||||
}
|
|
||||||
@@ -77,9 +77,6 @@ type ClientCommonConfig struct {
|
|||||||
|
|
||||||
// Include other config files for proxies.
|
// Include other config files for proxies.
|
||||||
IncludeConfigFiles []string `json:"includes,omitempty"`
|
IncludeConfigFiles []string `json:"includes,omitempty"`
|
||||||
|
|
||||||
// Store config enables the built-in store source (not configurable via sources list).
|
|
||||||
Store StoreConfig `json:"store,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ClientCommonConfig) Complete() error {
|
func (c *ClientCommonConfig) Complete() error {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/types"
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
@@ -124,7 +126,8 @@ func (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ProxyBaseConfig) Complete() {
|
func (c *ProxyBaseConfig) Complete(namePrefix string) {
|
||||||
|
c.Name = lo.Ternary(namePrefix == "", "", namePrefix+".") + c.Name
|
||||||
c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1")
|
c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1")
|
||||||
c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)
|
c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)
|
||||||
|
|
||||||
@@ -204,7 +207,7 @@ func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProxyConfigurer interface {
|
type ProxyConfigurer interface {
|
||||||
Complete()
|
Complete(namePrefix string)
|
||||||
GetBaseConfig() *ProxyBaseConfig
|
GetBaseConfig() *ProxyBaseConfig
|
||||||
// MarshalToMsg marshals this config into a msg.NewProxy message. This
|
// MarshalToMsg marshals this config into a msg.NewProxy message. This
|
||||||
// function will be called on the frpc side.
|
// function will be called on the frpc side.
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ type HTTPProxyPluginOptions struct {
|
|||||||
|
|
||||||
func (o *HTTPProxyPluginOptions) Complete() {}
|
func (o *HTTPProxyPluginOptions) Complete() {}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPS2HTTPPluginOptions struct {
|
type HTTPS2HTTPPluginOptions struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
LocalAddr string `json:"localAddr,omitempty"`
|
LocalAddr string `json:"localAddr,omitempty"`
|
||||||
@@ -125,6 +137,7 @@ type HTTPS2HTTPPluginOptions struct {
|
|||||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
|
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||||
@@ -139,6 +152,7 @@ type HTTPS2HTTPSPluginOptions struct {
|
|||||||
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
EnableHTTP2 *bool `json:"enableHTTP2,omitempty"`
|
||||||
CrtPath string `json:"crtPath,omitempty"`
|
CrtPath string `json:"crtPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
|
AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||||
@@ -180,10 +194,11 @@ type UnixDomainSocketPluginOptions struct {
|
|||||||
func (o *UnixDomainSocketPluginOptions) Complete() {}
|
func (o *UnixDomainSocketPluginOptions) Complete() {}
|
||||||
|
|
||||||
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() {}
|
||||||
|
|||||||
@@ -1,26 +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 v1
|
|
||||||
|
|
||||||
// StoreConfig configures the built-in store source.
|
|
||||||
type StoreConfig struct {
|
|
||||||
// Path is the store file path.
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEnabled returns true if the store is configured with a valid path.
|
|
||||||
func (c *StoreConfig) IsEnabled() bool {
|
|
||||||
return c.Path != ""
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,8 @@ package validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -49,6 +51,9 @@ func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
return errors.New("localAddr is required")
|
||||||
}
|
}
|
||||||
|
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||||
|
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +61,9 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
return errors.New("localAddr is required")
|
||||||
}
|
}
|
||||||
|
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||||
|
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,5 +85,29 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {
|
|||||||
if c.LocalAddr == "" {
|
if c.LocalAddr == "" {
|
||||||
return errors.New("localAddr is required")
|
return errors.New("localAddr is required")
|
||||||
}
|
}
|
||||||
|
if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil {
|
||||||
|
return fmt.Errorf("invalid autoTLS options: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error {
|
||||||
|
if c == nil || !c.Enable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if crtPath != "" || keyPath != "" {
|
||||||
|
return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.CacheDir) == "" {
|
||||||
|
return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true")
|
||||||
|
}
|
||||||
|
if len(c.HostAllowList) > 0 {
|
||||||
|
for _, host := range c.HostAllowList {
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
return errors.New("autoTLS.hostAllowList cannot contain empty domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
|
|||||||
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
|
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
|
||||||
for _, domain := range c.CustomDomains {
|
for _, domain := range c.CustomDomains {
|
||||||
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
|
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
|
||||||
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
|
if strings.Contains(domain, s.SubDomainHost) {
|
||||||
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
|
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,14 +56,26 @@ func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *VisitorBaseConfig) Complete() {
|
func (c *VisitorBaseConfig) Complete(g *ClientCommonConfig) {
|
||||||
if c.BindAddr == "" {
|
if c.BindAddr == "" {
|
||||||
c.BindAddr = "127.0.0.1"
|
c.BindAddr = "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namePrefix := ""
|
||||||
|
if g.User != "" {
|
||||||
|
namePrefix = g.User + "."
|
||||||
|
}
|
||||||
|
c.Name = namePrefix + c.Name
|
||||||
|
|
||||||
|
if c.ServerUser != "" {
|
||||||
|
c.ServerName = c.ServerUser + "." + c.ServerName
|
||||||
|
} else {
|
||||||
|
c.ServerName = namePrefix + c.ServerName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type VisitorConfigurer interface {
|
type VisitorConfigurer interface {
|
||||||
Complete()
|
Complete(*ClientCommonConfig)
|
||||||
GetBaseConfig() *VisitorBaseConfig
|
GetBaseConfig() *VisitorBaseConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +168,15 @@ type XTCPVisitorConfig struct {
|
|||||||
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
|
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *XTCPVisitorConfig) Complete() {
|
func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) {
|
||||||
c.VisitorBaseConfig.Complete()
|
c.VisitorBaseConfig.Complete(g)
|
||||||
|
|
||||||
c.Protocol = util.EmptyOr(c.Protocol, "quic")
|
c.Protocol = util.EmptyOr(c.Protocol, "quic")
|
||||||
c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8)
|
c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8)
|
||||||
c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)
|
c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)
|
||||||
c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)
|
c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)
|
||||||
|
|
||||||
|
if c.FallbackTo != "" {
|
||||||
|
c.FallbackTo = lo.Ternary(g.User == "", "", g.User+".") + c.FallbackTo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,8 +208,9 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
filterAll := proxyType == "" || proxyType == "all"
|
||||||
for name, proxyStats := range m.info.ProxyStatistics {
|
for name, proxyStats := range m.info.ProxyStatistics {
|
||||||
if proxyStats.ProxyType != proxyType {
|
if !filterAll && proxyStats.ProxyType != proxyType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +238,9 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
filterAll := proxyType == "" || proxyType == "all"
|
||||||
for name, proxyStats := range m.info.ProxyStatistics {
|
for name, proxyStats := range m.info.ProxyStatistics {
|
||||||
if proxyStats.ProxyType != proxyType {
|
if !filterAll && proxyStats.ProxyType != proxyType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.
|
|||||||
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
// Make hole-punching decisions based on the NAT information of the client and visitor.
|
||||||
vResp, cResp, err := c.analysis(session)
|
vResp, cResp, err := c.analysis(session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("sid [%s] analysis error: %v", err)
|
log.Debugf("sid [%s] analysis error: %v", sid, err)
|
||||||
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())
|
||||||
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
212
pkg/plugin/client/autotls.go
Normal file
212
pkg/plugin/client/autotls.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package 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)
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ type HTTPS2HTTPPlugin struct {
|
|||||||
s *http.Server
|
s *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||||
listener := NewProxyListener()
|
listener := NewProxyListener()
|
||||||
|
|
||||||
@@ -84,9 +84,18 @@ func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi
|
|||||||
rp.ServeHTTP(w, r)
|
rp.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
var tlsConfig *tls.Config
|
||||||
if err != nil {
|
var err error
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||||
|
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p.s = &http.Server{
|
p.s = &http.Server{
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ type HTTPS2HTTPSPlugin struct {
|
|||||||
s *http.Server
|
s *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
listener := NewProxyListener()
|
||||||
@@ -90,9 +90,18 @@ func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plug
|
|||||||
rp.ServeHTTP(w, r)
|
rp.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
var tlsConfig *tls.Config
|
||||||
if err != nil {
|
var err error
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||||
|
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build autoTLS config error: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p.s = &http.Server{
|
p.s = &http.Server{
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
|
|
||||||
type PluginContext struct {
|
type PluginContext struct {
|
||||||
Name string
|
Name string
|
||||||
|
HostAllowList []string
|
||||||
VnetController *vnet.Controller
|
VnetController *vnet.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,16 +39,25 @@ type TLS2RawPlugin struct {
|
|||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.TLS2RawPluginOptions)
|
opts := options.(*v1.TLS2RawPluginOptions)
|
||||||
|
|
||||||
p := &TLS2RawPlugin{
|
p := &TLS2RawPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
var tlsConfig *tls.Config
|
||||||
if err != nil {
|
var err error
|
||||||
return nil, err
|
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
|
||||||
|
tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p.tlsConfig = tlsConfig
|
p.tlsConfig = tlsConfig
|
||||||
return p, nil
|
return p, nil
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (s *TunnelServer) Run() error {
|
|||||||
if sshConn.Permissions != nil {
|
if sshConn.Permissions != nil {
|
||||||
clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User)
|
clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User)
|
||||||
}
|
}
|
||||||
pc.Complete()
|
pc.Complete(clientCfg.User)
|
||||||
|
|
||||||
vc, err := virtual.NewClient(virtual.ClientOptions{
|
vc, err := virtual.NewClient(virtual.ClientOptions{
|
||||||
Common: clientCfg,
|
Common: clientCfg,
|
||||||
|
|||||||
18
pkg/util/banner/banner.go
Normal file
18
pkg/util/banner/banner.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package banner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DisplayBanner() {
|
||||||
|
fmt.Println(" __ ___ __________ ____ ________ ____")
|
||||||
|
fmt.Println(" / / ____ / (_)___ _/ ____/ __ \\/ __ \\ / ____/ / / _/")
|
||||||
|
fmt.Println(" / / / __ \\/ / / __ `/ /_ / /_/ / /_/ /_____/ / / / / / ")
|
||||||
|
fmt.Println(" / /___/ /_/ / / / /_/ / __/ / _, _/ ____/_____/ /___/ /____/ / ")
|
||||||
|
fmt.Println("/_____/\\____/_/_/\\__,_/_/ /_/ |_/_/ \\____/_____/___/ ")
|
||||||
|
fmt.Println(" ")
|
||||||
|
log.Infof("Nya! %s 启动中", version.Full())
|
||||||
|
}
|
||||||
@@ -16,13 +16,18 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/log"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TraceLevel = log.TraceLevel
|
TraceLevel = log.DebugLevel
|
||||||
DebugLevel = log.DebugLevel
|
DebugLevel = log.DebugLevel
|
||||||
InfoLevel = log.InfoLevel
|
InfoLevel = log.InfoLevel
|
||||||
WarnLevel = log.WarnLevel
|
WarnLevel = log.WarnLevel
|
||||||
@@ -32,39 +37,158 @@ var (
|
|||||||
var Logger *log.Logger
|
var Logger *log.Logger
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Logger = log.New(
|
Logger = log.NewWithOptions(os.Stderr, log.Options{
|
||||||
log.WithCaller(true),
|
ReportCaller: true,
|
||||||
log.AddCallerSkip(1),
|
ReportTimestamp: true,
|
||||||
log.WithLevel(log.InfoLevel),
|
TimeFormat: time.Kitchen,
|
||||||
)
|
Prefix: "LoliaFRP-CLI",
|
||||||
|
CallerOffset: 1,
|
||||||
|
})
|
||||||
|
// 设置自定义样式以支持 Trace 级别
|
||||||
|
styles := log.DefaultStyles()
|
||||||
|
styles.Levels[TraceLevel] = lipgloss.NewStyle().
|
||||||
|
SetString("TRACE").
|
||||||
|
Bold(true).
|
||||||
|
MaxWidth(5).
|
||||||
|
Foreground(lipgloss.Color("61"))
|
||||||
|
Logger.SetStyles(styles)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {
|
||||||
options := []log.Option{}
|
var output io.Writer
|
||||||
|
var err error
|
||||||
|
|
||||||
if logPath == "console" {
|
if logPath == "console" {
|
||||||
if !disableLogColor {
|
output = os.Stdout
|
||||||
options = append(options,
|
|
||||||
log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{
|
|
||||||
Colorful: true,
|
|
||||||
}, os.Stdout)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
writer := log.NewRotateFileWriter(log.RotateFileConfig{
|
// Use rotating file writer
|
||||||
FileName: logPath,
|
output, err = NewRotateFileWriter(logPath, maxDays)
|
||||||
Mode: log.RotateFileModeDaily,
|
if err != nil {
|
||||||
MaxDays: maxDays,
|
// Fallback to console if file creation fails
|
||||||
})
|
output = os.Stdout
|
||||||
writer.Init()
|
}
|
||||||
options = append(options, log.WithOutput(writer))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
level, err := log.ParseLevel(levelStr)
|
level, err := log.ParseLevel(levelStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
level = log.InfoLevel
|
level = log.InfoLevel
|
||||||
}
|
}
|
||||||
options = append(options, log.WithLevel(level))
|
|
||||||
Logger = Logger.WithOptions(options...)
|
Logger = log.NewWithOptions(output, log.Options{
|
||||||
|
ReportCaller: true,
|
||||||
|
ReportTimestamp: true,
|
||||||
|
TimeFormat: time.Kitchen,
|
||||||
|
Prefix: "LoliaFRP-CLI",
|
||||||
|
CallerOffset: 1,
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRotateFileWriter creates a rotating file writer
|
||||||
|
func NewRotateFileWriter(filePath string, maxDays int) (*RotateFileWriter, error) {
|
||||||
|
w := &RotateFileWriter{
|
||||||
|
filePath: filePath,
|
||||||
|
maxDays: maxDays,
|
||||||
|
lastRotate: time.Now(),
|
||||||
|
currentDate: time.Now().Format("2006-01-02"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.openFile(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateFileWriter implements io.Writer with daily rotation
|
||||||
|
type RotateFileWriter struct {
|
||||||
|
filePath string
|
||||||
|
maxDays int
|
||||||
|
file *os.File
|
||||||
|
lastRotate time.Time
|
||||||
|
currentDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) openFile() error {
|
||||||
|
var err error
|
||||||
|
w.file, err = os.OpenFile(w.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) checkRotate() error {
|
||||||
|
now := time.Now()
|
||||||
|
currentDate := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
if currentDate != w.currentDate {
|
||||||
|
// Close current file
|
||||||
|
if w.file != nil {
|
||||||
|
w.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename current file with date suffix
|
||||||
|
oldPath := w.filePath
|
||||||
|
newPath := w.filePath + "." + w.currentDate
|
||||||
|
if _, err := os.Stat(oldPath); err == nil {
|
||||||
|
if err := os.Rename(oldPath, newPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old log files
|
||||||
|
w.cleanupOldLogs(now)
|
||||||
|
|
||||||
|
// Update current date and open new file
|
||||||
|
w.currentDate = currentDate
|
||||||
|
w.lastRotate = now
|
||||||
|
return w.openFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotateFileWriter) cleanupOldLogs(now time.Time) {
|
||||||
|
if w.maxDays <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoffDate := now.AddDate(0, 0, -w.maxDays)
|
||||||
|
|
||||||
|
// Find and remove old log files
|
||||||
|
dir := filepath.Dir(w.filePath)
|
||||||
|
base := filepath.Base(w.filePath)
|
||||||
|
|
||||||
|
files, _ := os.ReadDir(dir)
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := f.Name()
|
||||||
|
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) {
|
||||||
@@ -75,6 +199,10 @@ func Warnf(format string, v ...any) {
|
|||||||
Logger.Warnf(format, v...)
|
Logger.Warnf(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Info(format string, v ...any) {
|
||||||
|
Logger.Info(format, v...)
|
||||||
|
}
|
||||||
|
|
||||||
func Infof(format string, v ...any) {
|
func Infof(format string, v ...any) {
|
||||||
Logger.Infof(format, v...)
|
Logger.Infof(format, v...)
|
||||||
}
|
}
|
||||||
@@ -84,11 +212,12 @@ func Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Tracef(format string, v ...any) {
|
func Tracef(format string, v ...any) {
|
||||||
Logger.Tracef(format, v...)
|
Logger.Logf(TraceLevel, format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logf(level log.Level, offset int, format string, v ...any) {
|
func Logf(level log.Level, offset int, format string, v ...any) {
|
||||||
Logger.Logf(level, offset, format, v...)
|
// charmbracelet/log doesn't support offset, so we ignore it
|
||||||
|
Logger.Logf(level, format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteLogger struct {
|
type WriteLogger struct {
|
||||||
@@ -104,6 +233,8 @@ func NewWriteLogger(level log.Level, offset int) *WriteLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
func (w *WriteLogger) Write(p []byte) (n int, err error) {
|
||||||
Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n")))
|
// charmbracelet/log doesn't support offset in Log
|
||||||
|
msg := string(bytes.TrimRight(p, "\n"))
|
||||||
|
Logger.Log(w.level, msg)
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// AddUserPrefix builds the wire-level proxy name for frps by prefixing user.
|
|
||||||
func AddUserPrefix(user, name string) string {
|
|
||||||
if user == "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return user + "." + name
|
|
||||||
}
|
|
||||||
|
|
||||||
// StripUserPrefix converts a wire-level proxy name to an internal raw name.
|
|
||||||
// It strips only one exact "{user}." prefix.
|
|
||||||
func StripUserPrefix(user, name string) string {
|
|
||||||
if user == "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
prefix := user + "."
|
|
||||||
if strings.HasPrefix(name, prefix) {
|
|
||||||
return strings.TrimPrefix(name, prefix)
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildTargetServerProxyName resolves visitor target proxy name for wire-level
|
|
||||||
// protocol messages. serverUser overrides local user when set.
|
|
||||||
func BuildTargetServerProxyName(localUser, serverUser, serverName string) string {
|
|
||||||
if serverUser != "" {
|
|
||||||
return AddUserPrefix(serverUser, serverName)
|
|
||||||
}
|
|
||||||
return AddUserPrefix(localUser, serverName)
|
|
||||||
}
|
|
||||||
@@ -41,23 +41,3 @@ func TestParseRangeNumbers(t *testing.T) {
|
|||||||
_, err = ParseRangeNumbers("3-a")
|
_, err = ParseRangeNumbers("3-a")
|
||||||
require.Error(err)
|
require.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddUserPrefix(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
require.Equal("test", AddUserPrefix("", "test"))
|
|
||||||
require.Equal("alice.test", AddUserPrefix("alice", "test"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripUserPrefix(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
require.Equal("test", StripUserPrefix("", "test"))
|
|
||||||
require.Equal("test", StripUserPrefix("alice", "alice.test"))
|
|
||||||
require.Equal("alice.test", StripUserPrefix("alice", "alice.alice.test"))
|
|
||||||
require.Equal("bob.test", StripUserPrefix("alice", "bob.test"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildTargetServerProxyName(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
require.Equal("alice.test", BuildTargetServerProxyName("alice", "", "test"))
|
|
||||||
require.Equal("bob.test", BuildTargetServerProxyName("alice", "bob", "test"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.67.0"
|
var version = "LoliaFRP-CLI 0.67.2"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -28,23 +28,70 @@ var NotFoundPagePath = ""
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
NotFound = `<!DOCTYPE html>
|
NotFound = `<!DOCTYPE html>
|
||||||
<html>
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<title>Not Found</title>
|
<meta charset="UTF-8">
|
||||||
<style>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
body {
|
<title>404 - 未绑定域名</title>
|
||||||
width: 35em;
|
<style>
|
||||||
margin: 0 auto;
|
body {
|
||||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
font-family: -apple-system, sans-serif;
|
||||||
}
|
display: flex;
|
||||||
</style>
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #666;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
text-align: left;
|
||||||
|
margin: 20px auto;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>The page you requested was not found.</h1>
|
<div class="container">
|
||||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
<h1>域名未绑定</h1>
|
||||||
Please try again later.</p>
|
<p>这个域名还没有绑定到任何隧道哦 (;д;)</p>
|
||||||
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
|
<p><strong>可能是这些原因:</strong></p>
|
||||||
<p><em>Faithfully yours, frp.</em></p>
|
<ul>
|
||||||
|
<li>域名配置不对,或者没有正确解析</li>
|
||||||
|
<li>隧道可能还没启动,或者已经停止</li>
|
||||||
|
<li>自定义域名忘记在服务端配置了</li>
|
||||||
|
</ul>
|
||||||
|
<div class="footer">由 <a href="https://lolia.link/">LoliaFRP</a> 与捐赠者们用爱发电</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
@@ -69,7 +116,7 @@ func getNotFoundPageContent() []byte {
|
|||||||
|
|
||||||
func NotFoundResponse() *http.Response {
|
func NotFoundResponse() *http.Response {
|
||||||
header := make(http.Header)
|
header := make(http.Header)
|
||||||
header.Set("server", "frp/"+version.Full())
|
header.Set("server", version.Full())
|
||||||
header.Set("Content-Type", "text/html")
|
header.Set("Content-Type", "text/html")
|
||||||
|
|
||||||
content := getNotFoundPageContent()
|
content := getNotFoundPageContent()
|
||||||
|
|||||||
@@ -111,5 +111,5 @@ func (l *Logger) Debugf(format string, v ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Tracef(format string, v ...any) {
|
func (l *Logger) Tracef(format string, v ...any) {
|
||||||
log.Logger.Tracef(l.prefixString+format, v...)
|
log.Logger.Logf(log.TraceLevel, l.prefixString+format, v...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
|
|
||||||
"github.com/fatedier/frp/client"
|
"github.com/fatedier/frp/client"
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
@@ -44,13 +43,10 @@ func NewClient(options ClientOptions) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ln := netpkg.NewInternalListener()
|
ln := netpkg.NewInternalListener()
|
||||||
configSource := source.NewConfigSource()
|
|
||||||
aggregator := source.NewAggregator(configSource)
|
|
||||||
|
|
||||||
serviceOptions := client.ServiceOptions{
|
serviceOptions := client.ServiceOptions{
|
||||||
Common: options.Common,
|
Common: options.Common,
|
||||||
ConfigSourceAggregator: aggregator,
|
ClientSpec: options.Spec,
|
||||||
ClientSpec: options.Spec,
|
|
||||||
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector {
|
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector {
|
||||||
return &pipeConnector{
|
return &pipeConnector{
|
||||||
peerListener: ln,
|
peerListener: ln,
|
||||||
|
|||||||
@@ -38,21 +38,28 @@ type Controller struct {
|
|||||||
serverCfg *v1.ServerConfig
|
serverCfg *v1.ServerConfig
|
||||||
clientRegistry *registry.ClientRegistry
|
clientRegistry *registry.ClientRegistry
|
||||||
pxyManager ProxyManager
|
pxyManager ProxyManager
|
||||||
|
ctlManager ControlManager
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyManager interface {
|
type ProxyManager interface {
|
||||||
GetByName(name string) (proxy.Proxy, bool)
|
GetByName(name string) (proxy.Proxy, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ControlManager interface {
|
||||||
|
CloseAllProxyByName(proxyName string) error
|
||||||
|
}
|
||||||
|
|
||||||
func NewController(
|
func NewController(
|
||||||
serverCfg *v1.ServerConfig,
|
serverCfg *v1.ServerConfig,
|
||||||
clientRegistry *registry.ClientRegistry,
|
clientRegistry *registry.ClientRegistry,
|
||||||
pxyManager ProxyManager,
|
pxyManager ProxyManager,
|
||||||
|
ctlManager ControlManager,
|
||||||
) *Controller {
|
) *Controller {
|
||||||
return &Controller{
|
return &Controller{
|
||||||
serverCfg: serverCfg,
|
serverCfg: serverCfg,
|
||||||
clientRegistry: clientRegistry,
|
clientRegistry: clientRegistry,
|
||||||
pxyManager: pxyManager,
|
pxyManager: pxyManager,
|
||||||
|
ctlManager: ctlManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +243,9 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
|
|||||||
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
|
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
|
||||||
}
|
}
|
||||||
proxyInfo.Status = "online"
|
proxyInfo.Status = "online"
|
||||||
|
c.fillProxyClientInfo(&proxyClientInfo{
|
||||||
|
clientVersion: &proxyInfo.ClientVersion,
|
||||||
|
}, pxy)
|
||||||
} else {
|
} else {
|
||||||
proxyInfo.Status = "offline"
|
proxyInfo.Status = "offline"
|
||||||
}
|
}
|
||||||
@@ -243,6 +253,24 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
|
|||||||
return proxyInfo, nil
|
return proxyInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/proxy/:name/close
|
||||||
|
func (c *Controller) APICloseProxyByName(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.ctlManager == nil {
|
||||||
|
return nil, fmt.Errorf("control manager unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ctlManager.CloseAllProxyByName(name); err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return httppkg.GeneralResponse{Code: 200, Msg: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/proxies?status=offline
|
// DELETE /api/proxies?status=offline
|
||||||
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||||
status := ctx.Query("status")
|
status := ctx.Query("status")
|
||||||
@@ -274,6 +302,9 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
proxyInfo.Status = "online"
|
proxyInfo.Status = "online"
|
||||||
|
c.fillProxyClientInfo(&proxyClientInfo{
|
||||||
|
clientVersion: &proxyInfo.ClientVersion,
|
||||||
|
}, pxy)
|
||||||
} else {
|
} else {
|
||||||
proxyInfo.Status = "offline"
|
proxyInfo.Status = "offline"
|
||||||
}
|
}
|
||||||
@@ -333,7 +364,6 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
|||||||
User: info.User,
|
User: info.User,
|
||||||
ClientID: info.ClientID(),
|
ClientID: info.ClientID(),
|
||||||
RunID: info.RunID,
|
RunID: info.RunID,
|
||||||
Version: info.Version,
|
|
||||||
Hostname: info.Hostname,
|
Hostname: info.Hostname,
|
||||||
ClientIP: info.IP,
|
ClientIP: info.IP,
|
||||||
FirstConnectedAt: toUnix(info.FirstConnectedAt),
|
FirstConnectedAt: toUnix(info.FirstConnectedAt),
|
||||||
@@ -346,6 +376,37 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type proxyClientInfo struct {
|
||||||
|
user *string
|
||||||
|
clientID *string
|
||||||
|
clientVersion *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) {
|
||||||
|
loginMsg := pxy.GetLoginMsg()
|
||||||
|
if loginMsg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if proxyInfo.user != nil {
|
||||||
|
*proxyInfo.user = loginMsg.User
|
||||||
|
}
|
||||||
|
if proxyInfo.clientVersion != nil {
|
||||||
|
*proxyInfo.clientVersion = loginMsg.Version
|
||||||
|
}
|
||||||
|
if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok {
|
||||||
|
if proxyInfo.clientID != nil {
|
||||||
|
*proxyInfo.clientID = info.ClientID()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if proxyInfo.clientID != nil {
|
||||||
|
*proxyInfo.clientID = loginMsg.ClientID
|
||||||
|
if *proxyInfo.clientID == "" {
|
||||||
|
*proxyInfo.clientID = loginMsg.RunID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toUnix(t time.Time) int64 {
|
func toUnix(t time.Time) int64 {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ type ClientInfoResp struct {
|
|||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
ClientID string `json:"clientID"`
|
ClientID string `json:"clientID"`
|
||||||
RunID string `json:"runID"`
|
RunID string `json:"runID"`
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
ClientIP string `json:"clientIP,omitempty"`
|
ClientIP string `json:"clientIP,omitempty"`
|
||||||
FirstConnectedAt int64 `json:"firstConnectedAt"`
|
FirstConnectedAt int64 `json:"firstConnectedAt"`
|
||||||
@@ -101,6 +100,7 @@ type ProxyStatsInfo struct {
|
|||||||
Conf any `json:"conf"`
|
Conf any `json:"conf"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
ClientID string `json:"clientID,omitempty"`
|
ClientID string `json:"clientID,omitempty"`
|
||||||
|
ClientVersion string `json:"clientVersion,omitempty"`
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||||
CurConns int64 `json:"curConns"`
|
CurConns int64 `json:"curConns"`
|
||||||
@@ -119,6 +119,7 @@ type GetProxyStatsResp struct {
|
|||||||
Conf any `json:"conf"`
|
Conf any `json:"conf"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
ClientID string `json:"clientID,omitempty"`
|
ClientID string `json:"clientID,omitempty"`
|
||||||
|
ClientVersion string `json:"clientVersion,omitempty"`
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||||
CurConns int64 `json:"curConns"`
|
CurConns int64 `json:"curConns"`
|
||||||
|
|||||||
@@ -95,6 +95,51 @@ func (cm *ControlManager) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseAllProxyByName Finds the tunnel name and closes all tunnels on the same connection.
|
||||||
|
func (cm *ControlManager) CloseAllProxyByName(proxyName string) error {
|
||||||
|
cm.mu.RLock()
|
||||||
|
var target *Control
|
||||||
|
for _, ctl := range cm.ctlsByRunID {
|
||||||
|
ctl.mu.RLock()
|
||||||
|
_, ok := ctl.proxies[proxyName]
|
||||||
|
ctl.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
target = ctl
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
if target == nil {
|
||||||
|
return fmt.Errorf("no proxy found with name [%s]", proxyName)
|
||||||
|
}
|
||||||
|
return target.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickByProxyName finds the Control that manages the given proxy (tunnel) name and closes
|
||||||
|
// Bug: The client does not display the kickout message.
|
||||||
|
func (cm *ControlManager) KickByProxyName(proxyName string) error {
|
||||||
|
cm.mu.RLock()
|
||||||
|
var target *Control
|
||||||
|
for _, ctl := range cm.ctlsByRunID {
|
||||||
|
ctl.mu.RLock()
|
||||||
|
_, ok := ctl.proxies[proxyName]
|
||||||
|
ctl.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
target = ctl
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
|
||||||
|
if target == nil {
|
||||||
|
return fmt.Errorf("no proxy found with name [%s]", proxyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
xl := target.xl
|
||||||
|
xl.Infof("kick client with proxy [%s] by server administrator request", proxyName)
|
||||||
|
return target.Close()
|
||||||
|
}
|
||||||
|
|
||||||
type Control struct {
|
type Control struct {
|
||||||
// all resource managers and controllers
|
// all resource managers and controllers
|
||||||
rc *controller.ResourceController
|
rc *controller.ResourceController
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ type ClientInfo struct {
|
|||||||
RunID string
|
RunID string
|
||||||
Hostname string
|
Hostname string
|
||||||
IP string
|
IP string
|
||||||
Version string
|
|
||||||
FirstConnectedAt time.Time
|
FirstConnectedAt time.Time
|
||||||
LastConnectedAt time.Time
|
LastConnectedAt time.Time
|
||||||
DisconnectedAt time.Time
|
DisconnectedAt time.Time
|
||||||
@@ -51,7 +50,7 @@ func NewClientRegistry() *ClientRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
||||||
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr string) (key string, conflict bool) {
|
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
|
||||||
if runID == "" {
|
if runID == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -87,7 +86,6 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
|||||||
info.RunID = runID
|
info.RunID = runID
|
||||||
info.Hostname = hostname
|
info.Hostname = hostname
|
||||||
info.IP = remoteAddr
|
info.IP = remoteAddr
|
||||||
info.Version = version
|
|
||||||
if info.FirstConnectedAt.IsZero() {
|
if info.FirstConnectedAt.IsZero() {
|
||||||
info.FirstConnectedAt = now
|
info.FirstConnectedAt = now
|
||||||
}
|
}
|
||||||
@@ -153,6 +151,22 @@ func (info ClientInfo) ClientID() string {
|
|||||||
return info.RunID
|
return info.RunID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByRunID retrieves a client by its run ID.
|
||||||
|
func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) {
|
||||||
|
cr.mu.RLock()
|
||||||
|
defer cr.mu.RUnlock()
|
||||||
|
|
||||||
|
key, ok := cr.runIndex[runID]
|
||||||
|
if !ok {
|
||||||
|
return ClientInfo{}, false
|
||||||
|
}
|
||||||
|
info, ok := cr.clients[key]
|
||||||
|
if !ok {
|
||||||
|
return ClientInfo{}, false
|
||||||
|
}
|
||||||
|
return *info, true
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *ClientRegistry) composeClientKey(user, id string) string {
|
func (cr *ClientRegistry) composeClientKey(user, id string) string {
|
||||||
switch {
|
switch {
|
||||||
case user == "":
|
case user == "":
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
|
|||||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
remoteAddr = host
|
remoteAddr = host
|
||||||
}
|
}
|
||||||
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr)
|
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr)
|
||||||
if conflict {
|
if conflict {
|
||||||
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
||||||
ctl.Close()
|
ctl.Close()
|
||||||
@@ -703,12 +703,13 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.Handle("/metrics", promhttp.Handler())
|
subRouter.Handle("/metrics", promhttp.Handler())
|
||||||
}
|
}
|
||||||
|
|
||||||
apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
|
apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
|
||||||
|
|
||||||
// apis
|
// apis
|
||||||
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
||||||
|
subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
|
||||||
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
package features
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|
||||||
f := framework.NewDefaultFramework()
|
|
||||||
|
|
||||||
ginkgo.Describe("Store API", func() {
|
|
||||||
ginkgo.It("create proxy via API and verify connection", func() {
|
|
||||||
adminPort := f.AllocPort()
|
|
||||||
remotePort := f.AllocPort()
|
|
||||||
|
|
||||||
serverConf := consts.DefaultServerConfig
|
|
||||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
|
||||||
webServer.addr = "127.0.0.1"
|
|
||||||
webServer.port = %d
|
|
||||||
|
|
||||||
[store]
|
|
||||||
path = "%s/store.json"
|
|
||||||
`, adminPort, f.TempDirectory)
|
|
||||||
|
|
||||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
proxyConfig := map[string]any{
|
|
||||||
"name": "test-tcp",
|
|
||||||
"type": "tcp",
|
|
||||||
"localIP": "127.0.0.1",
|
|
||||||
"localPort": f.PortByName(framework.TCPEchoServerPort),
|
|
||||||
"remotePort": remotePort,
|
|
||||||
}
|
|
||||||
proxyBody, _ := json.Marshal(proxyConfig)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}).Body(proxyBody)
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
|
||||||
})
|
|
||||||
|
|
||||||
ginkgo.It("update proxy via API", func() {
|
|
||||||
adminPort := f.AllocPort()
|
|
||||||
remotePort1 := f.AllocPort()
|
|
||||||
remotePort2 := f.AllocPort()
|
|
||||||
|
|
||||||
serverConf := consts.DefaultServerConfig
|
|
||||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
|
||||||
webServer.addr = "127.0.0.1"
|
|
||||||
webServer.port = %d
|
|
||||||
|
|
||||||
[store]
|
|
||||||
path = "%s/store.json"
|
|
||||||
`, adminPort, f.TempDirectory)
|
|
||||||
|
|
||||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
proxyConfig := map[string]any{
|
|
||||||
"name": "test-tcp",
|
|
||||||
"type": "tcp",
|
|
||||||
"localIP": "127.0.0.1",
|
|
||||||
"localPort": f.PortByName(framework.TCPEchoServerPort),
|
|
||||||
"remotePort": remotePort1,
|
|
||||||
}
|
|
||||||
proxyBody, _ := json.Marshal(proxyConfig)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}).Body(proxyBody)
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
framework.NewRequestExpect(f).Port(remotePort1).Ensure()
|
|
||||||
|
|
||||||
proxyConfig["remotePort"] = remotePort2
|
|
||||||
proxyBody, _ = json.Marshal(proxyConfig)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("PUT", "", "/api/store/proxies/test-tcp", map[string]string{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}).Body(proxyBody)
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
|
|
||||||
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
ginkgo.It("delete proxy via API", func() {
|
|
||||||
adminPort := f.AllocPort()
|
|
||||||
remotePort := f.AllocPort()
|
|
||||||
|
|
||||||
serverConf := consts.DefaultServerConfig
|
|
||||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
|
||||||
webServer.addr = "127.0.0.1"
|
|
||||||
webServer.port = %d
|
|
||||||
|
|
||||||
[store]
|
|
||||||
path = "%s/store.json"
|
|
||||||
`, adminPort, f.TempDirectory)
|
|
||||||
|
|
||||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
proxyConfig := map[string]any{
|
|
||||||
"name": "test-tcp",
|
|
||||||
"type": "tcp",
|
|
||||||
"localIP": "127.0.0.1",
|
|
||||||
"localPort": f.PortByName(framework.TCPEchoServerPort),
|
|
||||||
"remotePort": remotePort,
|
|
||||||
}
|
|
||||||
proxyBody, _ := json.Marshal(proxyConfig)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}).Body(proxyBody)
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("DELETE", "", "/api/store/proxies/test-tcp", nil)
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
ginkgo.It("list and get proxy via API", func() {
|
|
||||||
adminPort := f.AllocPort()
|
|
||||||
remotePort := f.AllocPort()
|
|
||||||
|
|
||||||
serverConf := consts.DefaultServerConfig
|
|
||||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
|
||||||
webServer.addr = "127.0.0.1"
|
|
||||||
webServer.port = %d
|
|
||||||
|
|
||||||
[store]
|
|
||||||
path = "%s/store.json"
|
|
||||||
`, adminPort, f.TempDirectory)
|
|
||||||
|
|
||||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
proxyConfig := map[string]any{
|
|
||||||
"name": "test-tcp",
|
|
||||||
"type": "tcp",
|
|
||||||
"localIP": "127.0.0.1",
|
|
||||||
"localPort": f.PortByName(framework.TCPEchoServerPort),
|
|
||||||
"remotePort": remotePort,
|
|
||||||
}
|
|
||||||
proxyBody, _ := json.Marshal(proxyConfig)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}).Body(proxyBody)
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp")
|
|
||||||
})
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp")
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp")
|
|
||||||
})
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/nonexistent")
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 404
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
ginkgo.It("store disabled returns 404", func() {
|
|
||||||
adminPort := f.AllocPort()
|
|
||||||
|
|
||||||
serverConf := consts.DefaultServerConfig
|
|
||||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
|
||||||
webServer.addr = "127.0.0.1"
|
|
||||||
webServer.port = %d
|
|
||||||
`, adminPort)
|
|
||||||
|
|
||||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
|
|
||||||
}).Ensure(func(resp *request.Response) bool {
|
|
||||||
return resp.Code == 404
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
14
todo.md
14
todo.md
@@ -1,14 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
- [ ] Disabled proxy 在前端不显示的问题
|
|
||||||
- 当前行为:`enabled: false` 的代理在 `pkg/config/load.go` 中被过滤,不会加载到 proxy manager,前端无法看到
|
|
||||||
- 需要考虑:是否应该在前端显示 disabled 的代理(以灰色或其他方式标识),并允许用户启用/禁用
|
|
||||||
|
|
||||||
- [ ] Store proxy 删除后前端列表没有及时刷新
|
|
||||||
- 原因:`RemoveProxy` 通过 `notifyChangeUnlocked()` 异步通知变更,前端立即调用 `fetchData()` 时 proxy manager 可能还没处理完
|
|
||||||
- 可能的解决方案:
|
|
||||||
1. 后端删除 API 等待 proxy manager 更新完成后再返回
|
|
||||||
2. 前端乐观更新,先从列表移除再后台刷新
|
|
||||||
3. 前端适当延迟后再刷新(不优雅)
|
|
||||||
12
web/frpc/components.d.ts
vendored
12
web/frpc/components.d.ts
vendored
@@ -10,23 +10,13 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
|
|
||||||
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
22
web/frpc/package-lock.json
generated
22
web/frpc/package-lock.json
generated
@@ -1446,6 +1446,7 @@
|
|||||||
"node_modules/@types/lodash-es": {
|
"node_modules/@types/lodash-es": {
|
||||||
"version": "4.17.12",
|
"version": "4.17.12",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1454,6 +1455,7 @@
|
|||||||
"version": "24.10.4",
|
"version": "24.10.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1506,6 +1508,7 @@
|
|||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.20.0",
|
"@typescript-eslint/scope-manager": "6.20.0",
|
||||||
"@typescript-eslint/types": "6.20.0",
|
"@typescript-eslint/types": "6.20.0",
|
||||||
@@ -1906,6 +1909,7 @@
|
|||||||
"version": "14.1.0",
|
"version": "14.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vueuse/metadata": "14.1.0",
|
"@vueuse/metadata": "14.1.0",
|
||||||
@@ -1941,6 +1945,7 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2789,6 +2794,7 @@
|
|||||||
"version": "8.56.0",
|
"version": "8.56.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -2843,6 +2849,7 @@
|
|||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -2883,6 +2890,7 @@
|
|||||||
"version": "9.33.0",
|
"version": "9.33.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.4.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"globals": "^13.24.0",
|
"globals": "^13.24.0",
|
||||||
@@ -3970,13 +3978,15 @@
|
|||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -4521,6 +4531,7 @@
|
|||||||
"version": "3.7.4",
|
"version": "3.7.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4683,6 +4694,7 @@
|
|||||||
"version": "4.55.1",
|
"version": "4.55.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -5074,6 +5086,7 @@
|
|||||||
"version": "5.44.1",
|
"version": "5.44.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
@@ -5135,6 +5148,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5210,6 +5224,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5609,6 +5624,7 @@
|
|||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5721,6 +5737,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5736,6 +5753,7 @@
|
|||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.26",
|
"version": "3.5.26",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.26",
|
"@vue/compiler-dom": "3.5.26",
|
||||||
"@vue/compiler-sfc": "3.5.26",
|
"@vue/compiler-sfc": "3.5.26",
|
||||||
|
|||||||
@@ -65,10 +65,6 @@ const isDark = useDark()
|
|||||||
const currentRouteName = computed(() => {
|
const currentRouteName = computed(() => {
|
||||||
if (route.path === '/') return 'Overview'
|
if (route.path === '/') return 'Overview'
|
||||||
if (route.path === '/configure') return 'Configure'
|
if (route.path === '/configure') return 'Configure'
|
||||||
if (route.path === '/proxies/create') return 'Create Proxy'
|
|
||||||
if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit')) return 'Edit Proxy'
|
|
||||||
if (route.path === '/visitors/create') return 'Create Visitor'
|
|
||||||
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit')) return 'Edit Visitor'
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { http } from './http'
|
import { http } from './http'
|
||||||
import type {
|
import type { StatusResponse } from '../types/proxy'
|
||||||
StatusResponse,
|
|
||||||
StoreProxyListResp,
|
|
||||||
StoreProxyConfig,
|
|
||||||
StoreVisitorListResp,
|
|
||||||
StoreVisitorConfig,
|
|
||||||
} from '../types/proxy'
|
|
||||||
|
|
||||||
export const getStatus = () => {
|
export const getStatus = () => {
|
||||||
return http.get<StatusResponse>('/api/status')
|
return http.get<StatusResponse>('/api/status')
|
||||||
@@ -22,58 +16,3 @@ export const putConfig = (content: string) => {
|
|||||||
export const reloadConfig = () => {
|
export const reloadConfig = () => {
|
||||||
return http.get<void>('/api/reload')
|
return http.get<void>('/api/reload')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store API - Proxies
|
|
||||||
export const listStoreProxies = () => {
|
|
||||||
return http.get<StoreProxyListResp>('/api/store/proxies')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStoreProxy = (name: string) => {
|
|
||||||
return http.get<StoreProxyConfig>(
|
|
||||||
`/api/store/proxies/${encodeURIComponent(name)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createStoreProxy = (config: Record<string, any>) => {
|
|
||||||
return http.post<void>('/api/store/proxies', config)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateStoreProxy = (name: string, config: Record<string, any>) => {
|
|
||||||
return http.put<void>(
|
|
||||||
`/api/store/proxies/${encodeURIComponent(name)}`,
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteStoreProxy = (name: string) => {
|
|
||||||
return http.delete<void>(`/api/store/proxies/${encodeURIComponent(name)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store API - Visitors
|
|
||||||
export const listStoreVisitors = () => {
|
|
||||||
return http.get<StoreVisitorListResp>('/api/store/visitors')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStoreVisitor = (name: string) => {
|
|
||||||
return http.get<StoreVisitorConfig>(
|
|
||||||
`/api/store/visitors/${encodeURIComponent(name)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createStoreVisitor = (config: Record<string, any>) => {
|
|
||||||
return http.post<void>('/api/store/visitors', config)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateStoreVisitor = (
|
|
||||||
name: string,
|
|
||||||
config: Record<string, any>,
|
|
||||||
) => {
|
|
||||||
return http.put<void>(
|
|
||||||
`/api/store/visitors/${encodeURIComponent(name)}`,
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteStoreVisitor = (name: string) => {
|
|
||||||
return http.delete<void>(`/api/store/visitors/${encodeURIComponent(name)}`)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="kv-editor">
|
|
||||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
|
||||||
<el-input
|
|
||||||
:model-value="entry.key"
|
|
||||||
:placeholder="keyPlaceholder"
|
|
||||||
class="kv-input"
|
|
||||||
@update:model-value="updateEntry(index, 'key', $event)"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
:model-value="entry.value"
|
|
||||||
:placeholder="valuePlaceholder"
|
|
||||||
class="kv-input"
|
|
||||||
@update:model-value="updateEntry(index, 'value', $event)"
|
|
||||||
/>
|
|
||||||
<button class="kv-remove-btn" @click="removeEntry(index)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="kv-add-btn" @click="addEntry">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface KVEntry {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue: KVEntry[]
|
|
||||||
keyPlaceholder?: string
|
|
||||||
valuePlaceholder?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
keyPlaceholder: 'Key',
|
|
||||||
valuePlaceholder: 'Value',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: KVEntry[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const updateEntry = (index: number, field: 'key' | 'value', val: string) => {
|
|
||||||
const updated = [...props.modelValue]
|
|
||||||
updated[index] = { ...updated[index], [field]: val }
|
|
||||||
emit('update:modelValue', updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
|
||||||
const updated = props.modelValue.filter((_, i) => i !== index)
|
|
||||||
emit('update:modelValue', updated)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.kv-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-remove-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--el-fill-color);
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-remove-btn svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-remove-btn:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .kv-remove-btn:hover {
|
|
||||||
background: rgba(248, 113, 113, 0.15);
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-add-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: 1px dashed var(--el-border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-add-btn svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-add-btn:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
border-color: var(--el-color-primary);
|
|
||||||
background: var(--el-color-primary-light-9);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,46 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
|
||||||
class="proxy-card"
|
|
||||||
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
|
|
||||||
>
|
|
||||||
<div class="card-main">
|
<div class="card-main">
|
||||||
<div class="card-left">
|
<div class="card-left">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="proxy-name">{{ proxy.name }}</span>
|
<span class="proxy-name">{{ proxy.name }}</span>
|
||||||
<span class="type-tag" :class="`type-${proxy.type}`">{{
|
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
|
||||||
proxy.type.toUpperCase()
|
|
||||||
}}</span>
|
|
||||||
<span v-if="isStore" class="source-tag">
|
|
||||||
<svg
|
|
||||||
class="store-icon"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Store
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<span v-if="proxy.local_addr" class="meta-item">
|
<span v-if="proxy.local_addr" class="meta-item">
|
||||||
<span class="meta-label">Local</span>
|
<span class="meta-label">Local:</span>
|
||||||
<span class="meta-value code">{{ proxy.local_addr }}</span>
|
<span class="meta-value code">{{ proxy.local_addr }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="proxy.plugin" class="meta-item">
|
<span v-if="proxy.plugin" class="meta-item">
|
||||||
<span class="meta-label">Plugin</span>
|
<span class="meta-label">Plugin:</span>
|
||||||
<span class="meta-value code">{{ proxy.plugin }}</span>
|
<span class="meta-value code">{{ proxy.plugin }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="proxy.remote_addr" class="meta-item">
|
<span v-if="proxy.remote_addr" class="meta-item">
|
||||||
<span class="meta-label">Remote</span>
|
<span class="meta-label">Remote:</span>
|
||||||
<span class="meta-value code">{{ proxy.remote_addr }}</span>
|
<span class="meta-value code">{{ proxy.remote_addr }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,58 +25,12 @@
|
|||||||
|
|
||||||
<div class="card-right">
|
<div class="card-right">
|
||||||
<div v-if="proxy.err" class="error-info">
|
<div v-if="proxy.err" class="error-info">
|
||||||
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
|
<el-icon class="error-icon"><Warning /></el-icon>
|
||||||
<div class="error-badge">
|
<span class="error-text">{{ proxy.err }}</span>
|
||||||
<el-icon class="error-icon"><Warning /></el-icon>
|
|
||||||
<span class="error-text">Error</span>
|
|
||||||
</div>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-badge" :class="statusClass">
|
<div class="status-badge" :class="statusClass">
|
||||||
<span class="status-dot"></span>
|
|
||||||
{{ proxy.status }}
|
{{ proxy.status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Store actions -->
|
|
||||||
<div v-if="isStore" class="card-actions">
|
|
||||||
<button
|
|
||||||
class="action-btn edit-btn"
|
|
||||||
@click.stop="$emit('edit', proxy)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="action-btn delete-btn"
|
|
||||||
@click.stop="$emit('delete', proxy)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,13 +47,6 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
edit: [proxy: ProxyStatus]
|
|
||||||
delete: [proxy: ProxyStatus]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isStore = computed(() => props.proxy.source === 'store')
|
|
||||||
|
|
||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
switch (props.proxy.status) {
|
switch (props.proxy.status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
@@ -137,20 +61,17 @@ const statusClass = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.proxy-card {
|
.proxy-card {
|
||||||
position: relative;
|
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s ease-in-out;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-card:hover {
|
.proxy-card:hover {
|
||||||
border-color: var(--el-border-color);
|
border-color: var(--el-border-color-light);
|
||||||
box-shadow:
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||||
0 4px 16px rgba(0, 0, 0, 0.06),
|
|
||||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-card.has-error {
|
.proxy-card.has-error {
|
||||||
@@ -165,9 +86,9 @@ html.dark .proxy-card.has-error {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 18px 20px;
|
padding: 20px 24px;
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
min-height: 76px;
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left Section */
|
/* Left Section */
|
||||||
@@ -175,7 +96,7 @@ html.dark .proxy-card.has-error {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -183,297 +104,120 @@ html.dark .proxy-card.has-error {
|
|||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-name {
|
.proxy-name {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
line-height: 1.3;
|
line-height: 1.4;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-tag {
|
.type-tag {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--el-fill-color);
|
background: var(--el-fill-color);
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag.type-tcp {
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
.type-tag.type-udp {
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
.type-tag.type-http {
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
.type-tag.type-https {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
.type-tag.type-stcp,
|
|
||||||
.type-tag.type-sudp,
|
|
||||||
.type-tag.type-xtcp {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: #8b5cf6;
|
|
||||||
}
|
|
||||||
.type-tag.type-tcpmux {
|
|
||||||
background: rgba(236, 72, 153, 0.1);
|
|
||||||
color: #ec4899;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .type-tag.type-tcp {
|
|
||||||
background: rgba(96, 165, 250, 0.15);
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
html.dark .type-tag.type-udp {
|
|
||||||
background: rgba(251, 191, 36, 0.15);
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
html.dark .type-tag.type-http {
|
|
||||||
background: rgba(52, 211, 153, 0.15);
|
|
||||||
color: #34d399;
|
|
||||||
}
|
|
||||||
html.dark .type-tag.type-https {
|
|
||||||
background: rgba(52, 211, 153, 0.2);
|
|
||||||
color: #34d399;
|
|
||||||
}
|
|
||||||
html.dark .type-tag.type-stcp,
|
|
||||||
html.dark .type-tag.type-sudp,
|
|
||||||
html.dark .type-tag.type-xtcp {
|
|
||||||
background: rgba(167, 139, 250, 0.15);
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
html.dark .type-tag.type-tcpmux {
|
|
||||||
background: rgba(244, 114, 182, 0.15);
|
|
||||||
color: #f472b6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(102, 126, 234, 0.1) 0%,
|
|
||||||
rgba(118, 75, 162, 0.1) 100%
|
|
||||||
);
|
|
||||||
color: #764ba2;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .source-tag {
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(129, 140, 248, 0.15) 0%,
|
|
||||||
rgba(167, 139, 250, 0.15) 100%
|
|
||||||
);
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-icon {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-meta {
|
.card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-item {
|
.meta-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-label {
|
.meta-label {
|
||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-value {
|
.meta-value {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-value.code {
|
.meta-value.code {
|
||||||
font-family:
|
font-family:
|
||||||
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
padding: 3px 7px;
|
padding: 2px 6px;
|
||||||
border-radius: 5px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right Section */
|
/* Right Section */
|
||||||
.card-right {
|
.card-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-badge {
|
.error-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
max-width: 200px;
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--el-color-danger-light-9);
|
|
||||||
cursor: help;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-icon {
|
.error-icon {
|
||||||
color: var(--el-color-danger);
|
color: var(--el-color-danger);
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-color-danger);
|
color: var(--el-color-danger);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
padding: 4px 12px;
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.running {
|
.status-badge.running {
|
||||||
background: var(--el-color-success-light-9);
|
background: var(--el-color-success-light-9);
|
||||||
color: var(--el-color-success);
|
color: var(--el-color-success);
|
||||||
}
|
}
|
||||||
.status-badge.running .status-dot {
|
|
||||||
background: var(--el-color-success);
|
|
||||||
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.error {
|
.status-badge.error {
|
||||||
background: var(--el-color-danger-light-9);
|
background: var(--el-color-danger-light-9);
|
||||||
color: var(--el-color-danger);
|
color: var(--el-color-danger);
|
||||||
}
|
}
|
||||||
.status-badge.error .status-dot {
|
|
||||||
background: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.waiting {
|
.status-badge.waiting {
|
||||||
background: var(--el-color-warning-light-9);
|
background: var(--el-color-warning-light-9);
|
||||||
color: var(--el-color-warning);
|
color: var(--el-color-warning);
|
||||||
}
|
}
|
||||||
.status-badge.waiting .status-dot {
|
|
||||||
background: var(--el-color-warning);
|
|
||||||
animation: pulse 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action buttons */
|
|
||||||
.card-actions {
|
|
||||||
display: none;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-card.is-store:hover .status-badge {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-card:hover .card-actions {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--el-fill-color);
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .edit-btn:hover {
|
|
||||||
background: rgba(96, 165, 250, 0.15);
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .delete-btn:hover {
|
|
||||||
background: rgba(248, 113, 113, 0.15);
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-main {
|
.card-main {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 14px 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-right {
|
.card-right {
|
||||||
@@ -481,12 +225,12 @@ html.dark .delete-btn:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-top: 1px solid var(--el-border-color-lighter);
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
padding-top: 14px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.error-info {
|
||||||
opacity: 1;
|
max-width: none;
|
||||||
transform: none;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import Overview from '../views/Overview.vue'
|
import Overview from '../views/Overview.vue'
|
||||||
import ClientConfigure from '../views/ClientConfigure.vue'
|
import ClientConfigure from '../views/ClientConfigure.vue'
|
||||||
import ProxyEdit from '../views/ProxyEdit.vue'
|
|
||||||
import VisitorEdit from '../views/VisitorEdit.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
@@ -17,26 +15,6 @@ const router = createRouter({
|
|||||||
name: 'ClientConfigure',
|
name: 'ClientConfigure',
|
||||||
component: ClientConfigure,
|
component: ClientConfigure,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/proxies/create',
|
|
||||||
name: 'ProxyCreate',
|
|
||||||
component: ProxyEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/proxies/:name/edit',
|
|
||||||
name: 'ProxyEdit',
|
|
||||||
component: ProxyEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/visitors/create',
|
|
||||||
name: 'VisitorCreate',
|
|
||||||
component: VisitorEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/visitors/:name/edit',
|
|
||||||
name: 'VisitorEdit',
|
|
||||||
component: VisitorEdit,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ========================================
|
|
||||||
// RUNTIME STATUS TYPES (from /api/status)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export interface ProxyStatus {
|
export interface ProxyStatus {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
@@ -10,635 +6,7 @@ export interface ProxyStatus {
|
|||||||
local_addr: string
|
local_addr: string
|
||||||
plugin: string
|
plugin: string
|
||||||
remote_addr: string
|
remote_addr: string
|
||||||
source?: 'store' | 'config'
|
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StatusResponse = Record<string, ProxyStatus[]>
|
export type StatusResponse = Record<string, ProxyStatus[]>
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// STORE API TYPES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export interface StoreProxyConfig {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
config: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoreVisitorConfig {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
config: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoreProxyListResp {
|
|
||||||
proxies: StoreProxyConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoreVisitorListResp {
|
|
||||||
visitors: StoreVisitorConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// CONSTANTS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export const PROXY_TYPES = [
|
|
||||||
'tcp',
|
|
||||||
'udp',
|
|
||||||
'http',
|
|
||||||
'https',
|
|
||||||
'stcp',
|
|
||||||
'sudp',
|
|
||||||
'xtcp',
|
|
||||||
'tcpmux',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type ProxyType = (typeof PROXY_TYPES)[number]
|
|
||||||
|
|
||||||
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
|
|
||||||
|
|
||||||
export type VisitorType = (typeof VISITOR_TYPES)[number]
|
|
||||||
|
|
||||||
export const PLUGIN_TYPES = [
|
|
||||||
'',
|
|
||||||
'http2https',
|
|
||||||
'http_proxy',
|
|
||||||
'https2http',
|
|
||||||
'https2https',
|
|
||||||
'http2http',
|
|
||||||
'socks5',
|
|
||||||
'static_file',
|
|
||||||
'unix_domain_socket',
|
|
||||||
'tls2raw',
|
|
||||||
'virtual_net',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type PluginType = (typeof PLUGIN_TYPES)[number]
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// FORM DATA INTERFACES
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export interface ProxyFormData {
|
|
||||||
// Base fields (ProxyBaseConfig)
|
|
||||||
name: string
|
|
||||||
type: ProxyType
|
|
||||||
enabled: boolean
|
|
||||||
|
|
||||||
// Backend (ProxyBackend)
|
|
||||||
localIP: string
|
|
||||||
localPort: number | undefined
|
|
||||||
pluginType: string
|
|
||||||
pluginConfig: Record<string, any>
|
|
||||||
|
|
||||||
// Transport (ProxyTransport)
|
|
||||||
useEncryption: boolean
|
|
||||||
useCompression: boolean
|
|
||||||
bandwidthLimit: string
|
|
||||||
bandwidthLimitMode: string
|
|
||||||
proxyProtocolVersion: string
|
|
||||||
|
|
||||||
// Load Balancer (LoadBalancerConfig)
|
|
||||||
loadBalancerGroup: string
|
|
||||||
loadBalancerGroupKey: string
|
|
||||||
|
|
||||||
// Health Check (HealthCheckConfig)
|
|
||||||
healthCheckType: string
|
|
||||||
healthCheckTimeoutSeconds: number | undefined
|
|
||||||
healthCheckMaxFailed: number | undefined
|
|
||||||
healthCheckIntervalSeconds: number | undefined
|
|
||||||
healthCheckPath: string
|
|
||||||
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
|
|
||||||
|
|
||||||
// Metadata & Annotations
|
|
||||||
metadatas: Array<{ key: string; value: string }>
|
|
||||||
annotations: Array<{ key: string; value: string }>
|
|
||||||
|
|
||||||
// TCP/UDP specific
|
|
||||||
remotePort: number | undefined
|
|
||||||
|
|
||||||
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
|
|
||||||
customDomains: string
|
|
||||||
subdomain: string
|
|
||||||
|
|
||||||
// HTTP specific (HTTPProxyConfig)
|
|
||||||
locations: string
|
|
||||||
httpUser: string
|
|
||||||
httpPassword: string
|
|
||||||
hostHeaderRewrite: string
|
|
||||||
requestHeaders: Array<{ key: string; value: string }>
|
|
||||||
responseHeaders: Array<{ key: string; value: string }>
|
|
||||||
routeByHTTPUser: string
|
|
||||||
|
|
||||||
// TCPMux specific
|
|
||||||
multiplexer: string
|
|
||||||
|
|
||||||
// STCP/SUDP/XTCP specific
|
|
||||||
secretKey: string
|
|
||||||
allowUsers: string
|
|
||||||
|
|
||||||
// XTCP specific (NatTraversalConfig)
|
|
||||||
natTraversalDisableAssistedAddrs: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VisitorFormData {
|
|
||||||
// Base fields (VisitorBaseConfig)
|
|
||||||
name: string
|
|
||||||
type: VisitorType
|
|
||||||
enabled: boolean
|
|
||||||
|
|
||||||
// Transport (VisitorTransport)
|
|
||||||
useEncryption: boolean
|
|
||||||
useCompression: boolean
|
|
||||||
|
|
||||||
// Connection
|
|
||||||
secretKey: string
|
|
||||||
serverUser: string
|
|
||||||
serverName: string
|
|
||||||
bindAddr: string
|
|
||||||
bindPort: number | undefined
|
|
||||||
|
|
||||||
// XTCP specific (XTCPVisitorConfig)
|
|
||||||
protocol: string
|
|
||||||
keepTunnelOpen: boolean
|
|
||||||
maxRetriesAnHour: number | undefined
|
|
||||||
minRetryInterval: number | undefined
|
|
||||||
fallbackTo: string
|
|
||||||
fallbackTimeoutMs: number | undefined
|
|
||||||
natTraversalDisableAssistedAddrs: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// DEFAULT FORM CREATORS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export function createDefaultProxyForm(): ProxyFormData {
|
|
||||||
return {
|
|
||||||
name: '',
|
|
||||||
type: 'tcp',
|
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
localIP: '127.0.0.1',
|
|
||||||
localPort: undefined,
|
|
||||||
pluginType: '',
|
|
||||||
pluginConfig: {},
|
|
||||||
|
|
||||||
useEncryption: false,
|
|
||||||
useCompression: false,
|
|
||||||
bandwidthLimit: '',
|
|
||||||
bandwidthLimitMode: 'client',
|
|
||||||
proxyProtocolVersion: '',
|
|
||||||
|
|
||||||
loadBalancerGroup: '',
|
|
||||||
loadBalancerGroupKey: '',
|
|
||||||
|
|
||||||
healthCheckType: '',
|
|
||||||
healthCheckTimeoutSeconds: undefined,
|
|
||||||
healthCheckMaxFailed: undefined,
|
|
||||||
healthCheckIntervalSeconds: undefined,
|
|
||||||
healthCheckPath: '',
|
|
||||||
healthCheckHTTPHeaders: [],
|
|
||||||
|
|
||||||
metadatas: [],
|
|
||||||
annotations: [],
|
|
||||||
|
|
||||||
remotePort: undefined,
|
|
||||||
|
|
||||||
customDomains: '',
|
|
||||||
subdomain: '',
|
|
||||||
|
|
||||||
locations: '',
|
|
||||||
httpUser: '',
|
|
||||||
httpPassword: '',
|
|
||||||
hostHeaderRewrite: '',
|
|
||||||
requestHeaders: [],
|
|
||||||
responseHeaders: [],
|
|
||||||
routeByHTTPUser: '',
|
|
||||||
|
|
||||||
multiplexer: 'httpconnect',
|
|
||||||
|
|
||||||
secretKey: '',
|
|
||||||
allowUsers: '',
|
|
||||||
|
|
||||||
natTraversalDisableAssistedAddrs: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultVisitorForm(): VisitorFormData {
|
|
||||||
return {
|
|
||||||
name: '',
|
|
||||||
type: 'stcp',
|
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
useEncryption: false,
|
|
||||||
useCompression: false,
|
|
||||||
|
|
||||||
secretKey: '',
|
|
||||||
serverUser: '',
|
|
||||||
serverName: '',
|
|
||||||
bindAddr: '127.0.0.1',
|
|
||||||
bindPort: undefined,
|
|
||||||
|
|
||||||
protocol: 'quic',
|
|
||||||
keepTunnelOpen: false,
|
|
||||||
maxRetriesAnHour: undefined,
|
|
||||||
minRetryInterval: undefined,
|
|
||||||
fallbackTo: '',
|
|
||||||
fallbackTimeoutMs: undefined,
|
|
||||||
natTraversalDisableAssistedAddrs: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// CONVERTERS: Form -> Store API
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export function formToStoreProxy(form: ProxyFormData): Record<string, any> {
|
|
||||||
const config: Record<string, any> = {
|
|
||||||
name: form.name,
|
|
||||||
type: form.type,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled (nil/true = enabled, false = disabled)
|
|
||||||
if (!form.enabled) {
|
|
||||||
config.enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backend - LocalIP/LocalPort
|
|
||||||
if (form.pluginType === '') {
|
|
||||||
// No plugin, use local backend
|
|
||||||
if (form.localIP && form.localIP !== '127.0.0.1') {
|
|
||||||
config.localIP = form.localIP
|
|
||||||
}
|
|
||||||
if (form.localPort != null) {
|
|
||||||
config.localPort = form.localPort
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Plugin backend
|
|
||||||
config.plugin = {
|
|
||||||
type: form.pluginType,
|
|
||||||
...form.pluginConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transport
|
|
||||||
if (
|
|
||||||
form.useEncryption ||
|
|
||||||
form.useCompression ||
|
|
||||||
form.bandwidthLimit ||
|
|
||||||
(form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') ||
|
|
||||||
form.proxyProtocolVersion
|
|
||||||
) {
|
|
||||||
config.transport = {}
|
|
||||||
if (form.useEncryption) config.transport.useEncryption = true
|
|
||||||
if (form.useCompression) config.transport.useCompression = true
|
|
||||||
if (form.bandwidthLimit)
|
|
||||||
config.transport.bandwidthLimit = form.bandwidthLimit
|
|
||||||
if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {
|
|
||||||
config.transport.bandwidthLimitMode = form.bandwidthLimitMode
|
|
||||||
}
|
|
||||||
if (form.proxyProtocolVersion) {
|
|
||||||
config.transport.proxyProtocolVersion = form.proxyProtocolVersion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Balancer
|
|
||||||
if (form.loadBalancerGroup) {
|
|
||||||
config.loadBalancer = {
|
|
||||||
group: form.loadBalancerGroup,
|
|
||||||
}
|
|
||||||
if (form.loadBalancerGroupKey) {
|
|
||||||
config.loadBalancer.groupKey = form.loadBalancerGroupKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health Check
|
|
||||||
if (form.healthCheckType) {
|
|
||||||
config.healthCheck = {
|
|
||||||
type: form.healthCheckType,
|
|
||||||
}
|
|
||||||
if (form.healthCheckTimeoutSeconds != null) {
|
|
||||||
config.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
|
|
||||||
}
|
|
||||||
if (form.healthCheckMaxFailed != null) {
|
|
||||||
config.healthCheck.maxFailed = form.healthCheckMaxFailed
|
|
||||||
}
|
|
||||||
if (form.healthCheckIntervalSeconds != null) {
|
|
||||||
config.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
|
|
||||||
}
|
|
||||||
if (form.healthCheckPath) {
|
|
||||||
config.healthCheck.path = form.healthCheckPath
|
|
||||||
}
|
|
||||||
if (form.healthCheckHTTPHeaders.length > 0) {
|
|
||||||
config.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
if (form.metadatas.length > 0) {
|
|
||||||
config.metadatas = Object.fromEntries(
|
|
||||||
form.metadatas.map((m) => [m.key, m.value]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Annotations
|
|
||||||
if (form.annotations.length > 0) {
|
|
||||||
config.annotations = Object.fromEntries(
|
|
||||||
form.annotations.map((a) => [a.key, a.value]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type-specific fields
|
|
||||||
if (form.type === 'tcp' || form.type === 'udp') {
|
|
||||||
if (form.remotePort != null) {
|
|
||||||
config.remotePort = form.remotePort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
|
|
||||||
// Domain config
|
|
||||||
if (form.customDomains) {
|
|
||||||
config.customDomains = form.customDomains
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
if (form.subdomain) {
|
|
||||||
config.subdomain = form.subdomain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.type === 'http') {
|
|
||||||
// HTTP specific
|
|
||||||
if (form.locations) {
|
|
||||||
config.locations = form.locations
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
if (form.httpUser) config.httpUser = form.httpUser
|
|
||||||
if (form.httpPassword) config.httpPassword = form.httpPassword
|
|
||||||
if (form.hostHeaderRewrite)
|
|
||||||
config.hostHeaderRewrite = form.hostHeaderRewrite
|
|
||||||
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
|
|
||||||
|
|
||||||
// Header operations
|
|
||||||
if (form.requestHeaders.length > 0) {
|
|
||||||
config.requestHeaders = {
|
|
||||||
set: Object.fromEntries(
|
|
||||||
form.requestHeaders.map((h) => [h.key, h.value]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (form.responseHeaders.length > 0) {
|
|
||||||
config.responseHeaders = {
|
|
||||||
set: Object.fromEntries(
|
|
||||||
form.responseHeaders.map((h) => [h.key, h.value]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.type === 'tcpmux') {
|
|
||||||
// TCPMux specific
|
|
||||||
if (form.httpUser) config.httpUser = form.httpUser
|
|
||||||
if (form.httpPassword) config.httpPassword = form.httpPassword
|
|
||||||
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
|
|
||||||
if (form.multiplexer && form.multiplexer !== 'httpconnect') {
|
|
||||||
config.multiplexer = form.multiplexer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
|
|
||||||
// Secure proxy types
|
|
||||||
if (form.secretKey) config.secretKey = form.secretKey
|
|
||||||
if (form.allowUsers) {
|
|
||||||
config.allowUsers = form.allowUsers
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.type === 'xtcp') {
|
|
||||||
// XTCP NAT traversal
|
|
||||||
if (form.natTraversalDisableAssistedAddrs) {
|
|
||||||
config.natTraversal = {
|
|
||||||
disableAssistedAddrs: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formToStoreVisitor(form: VisitorFormData): Record<string, any> {
|
|
||||||
const config: Record<string, any> = {
|
|
||||||
name: form.name,
|
|
||||||
type: form.type,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled
|
|
||||||
if (!form.enabled) {
|
|
||||||
config.enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transport
|
|
||||||
if (form.useEncryption || form.useCompression) {
|
|
||||||
config.transport = {}
|
|
||||||
if (form.useEncryption) config.transport.useEncryption = true
|
|
||||||
if (form.useCompression) config.transport.useCompression = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base fields
|
|
||||||
if (form.secretKey) config.secretKey = form.secretKey
|
|
||||||
if (form.serverUser) config.serverUser = form.serverUser
|
|
||||||
if (form.serverName) config.serverName = form.serverName
|
|
||||||
if (form.bindAddr && form.bindAddr !== '127.0.0.1') {
|
|
||||||
config.bindAddr = form.bindAddr
|
|
||||||
}
|
|
||||||
if (form.bindPort != null) {
|
|
||||||
config.bindPort = form.bindPort
|
|
||||||
}
|
|
||||||
|
|
||||||
// XTCP specific
|
|
||||||
if (form.type === 'xtcp') {
|
|
||||||
if (form.protocol && form.protocol !== 'quic') {
|
|
||||||
config.protocol = form.protocol
|
|
||||||
}
|
|
||||||
if (form.keepTunnelOpen) {
|
|
||||||
config.keepTunnelOpen = true
|
|
||||||
}
|
|
||||||
if (form.maxRetriesAnHour != null) {
|
|
||||||
config.maxRetriesAnHour = form.maxRetriesAnHour
|
|
||||||
}
|
|
||||||
if (form.minRetryInterval != null) {
|
|
||||||
config.minRetryInterval = form.minRetryInterval
|
|
||||||
}
|
|
||||||
if (form.fallbackTo) {
|
|
||||||
config.fallbackTo = form.fallbackTo
|
|
||||||
}
|
|
||||||
if (form.fallbackTimeoutMs != null) {
|
|
||||||
config.fallbackTimeoutMs = form.fallbackTimeoutMs
|
|
||||||
}
|
|
||||||
if (form.natTraversalDisableAssistedAddrs) {
|
|
||||||
config.natTraversal = {
|
|
||||||
disableAssistedAddrs: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// CONVERTERS: Store API -> Form
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData {
|
|
||||||
const c = config.config || {}
|
|
||||||
const form = createDefaultProxyForm()
|
|
||||||
|
|
||||||
form.name = config.name || ''
|
|
||||||
form.type = (config.type as ProxyType) || 'tcp'
|
|
||||||
form.enabled = c.enabled !== false
|
|
||||||
|
|
||||||
// Backend
|
|
||||||
form.localIP = c.localIP || '127.0.0.1'
|
|
||||||
form.localPort = c.localPort
|
|
||||||
if (c.plugin?.type) {
|
|
||||||
form.pluginType = c.plugin.type
|
|
||||||
form.pluginConfig = { ...c.plugin }
|
|
||||||
delete form.pluginConfig.type
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transport
|
|
||||||
if (c.transport) {
|
|
||||||
form.useEncryption = c.transport.useEncryption || false
|
|
||||||
form.useCompression = c.transport.useCompression || false
|
|
||||||
form.bandwidthLimit = c.transport.bandwidthLimit || ''
|
|
||||||
form.bandwidthLimitMode = c.transport.bandwidthLimitMode || 'client'
|
|
||||||
form.proxyProtocolVersion = c.transport.proxyProtocolVersion || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Balancer
|
|
||||||
if (c.loadBalancer) {
|
|
||||||
form.loadBalancerGroup = c.loadBalancer.group || ''
|
|
||||||
form.loadBalancerGroupKey = c.loadBalancer.groupKey || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health Check
|
|
||||||
if (c.healthCheck) {
|
|
||||||
form.healthCheckType = c.healthCheck.type || ''
|
|
||||||
form.healthCheckTimeoutSeconds = c.healthCheck.timeoutSeconds
|
|
||||||
form.healthCheckMaxFailed = c.healthCheck.maxFailed
|
|
||||||
form.healthCheckIntervalSeconds = c.healthCheck.intervalSeconds
|
|
||||||
form.healthCheckPath = c.healthCheck.path || ''
|
|
||||||
form.healthCheckHTTPHeaders = c.healthCheck.httpHeaders || []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
if (c.metadatas) {
|
|
||||||
form.metadatas = Object.entries(c.metadatas).map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
value: String(value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Annotations
|
|
||||||
if (c.annotations) {
|
|
||||||
form.annotations = Object.entries(c.annotations).map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
value: String(value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type-specific fields
|
|
||||||
form.remotePort = c.remotePort
|
|
||||||
|
|
||||||
// Domain config
|
|
||||||
if (Array.isArray(c.customDomains)) {
|
|
||||||
form.customDomains = c.customDomains.join(', ')
|
|
||||||
} else if (c.customDomains) {
|
|
||||||
form.customDomains = c.customDomains
|
|
||||||
}
|
|
||||||
form.subdomain = c.subdomain || ''
|
|
||||||
|
|
||||||
// HTTP specific
|
|
||||||
if (Array.isArray(c.locations)) {
|
|
||||||
form.locations = c.locations.join(', ')
|
|
||||||
} else if (c.locations) {
|
|
||||||
form.locations = c.locations
|
|
||||||
}
|
|
||||||
form.httpUser = c.httpUser || ''
|
|
||||||
form.httpPassword = c.httpPassword || ''
|
|
||||||
form.hostHeaderRewrite = c.hostHeaderRewrite || ''
|
|
||||||
form.routeByHTTPUser = c.routeByHTTPUser || ''
|
|
||||||
|
|
||||||
// Header operations
|
|
||||||
if (c.requestHeaders?.set) {
|
|
||||||
form.requestHeaders = Object.entries(c.requestHeaders.set).map(
|
|
||||||
([key, value]) => ({ key, value: String(value) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (c.responseHeaders?.set) {
|
|
||||||
form.responseHeaders = Object.entries(c.responseHeaders.set).map(
|
|
||||||
([key, value]) => ({ key, value: String(value) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TCPMux
|
|
||||||
form.multiplexer = c.multiplexer || 'httpconnect'
|
|
||||||
|
|
||||||
// Secure types
|
|
||||||
form.secretKey = c.secretKey || ''
|
|
||||||
if (Array.isArray(c.allowUsers)) {
|
|
||||||
form.allowUsers = c.allowUsers.join(', ')
|
|
||||||
} else if (c.allowUsers) {
|
|
||||||
form.allowUsers = c.allowUsers
|
|
||||||
}
|
|
||||||
|
|
||||||
// XTCP NAT traversal
|
|
||||||
form.natTraversalDisableAssistedAddrs =
|
|
||||||
c.natTraversal?.disableAssistedAddrs || false
|
|
||||||
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeVisitorToForm(
|
|
||||||
config: StoreVisitorConfig,
|
|
||||||
): VisitorFormData {
|
|
||||||
const c = config.config || {}
|
|
||||||
const form = createDefaultVisitorForm()
|
|
||||||
|
|
||||||
form.name = config.name || ''
|
|
||||||
form.type = (config.type as VisitorType) || 'stcp'
|
|
||||||
form.enabled = c.enabled !== false
|
|
||||||
|
|
||||||
// Transport
|
|
||||||
if (c.transport) {
|
|
||||||
form.useEncryption = c.transport.useEncryption || false
|
|
||||||
form.useCompression = c.transport.useCompression || false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base fields
|
|
||||||
form.secretKey = c.secretKey || ''
|
|
||||||
form.serverUser = c.serverUser || ''
|
|
||||||
form.serverName = c.serverName || ''
|
|
||||||
form.bindAddr = c.bindAddr || '127.0.0.1'
|
|
||||||
form.bindPort = c.bindPort
|
|
||||||
|
|
||||||
// XTCP specific
|
|
||||||
form.protocol = c.protocol || 'quic'
|
|
||||||
form.keepTunnelOpen = c.keepTunnelOpen || false
|
|
||||||
form.maxRetriesAnHour = c.maxRetriesAnHour
|
|
||||||
form.minRetryInterval = c.minRetryInterval
|
|
||||||
form.fallbackTo = c.fallbackTo || ''
|
|
||||||
form.fallbackTimeoutMs = c.fallbackTimeoutMs
|
|
||||||
form.natTraversalDisableAssistedAddrs =
|
|
||||||
c.natTraversal?.disableAssistedAddrs || false
|
|
||||||
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,28 +48,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-select
|
|
||||||
v-model="filterSource"
|
|
||||||
placeholder="Source"
|
|
||||||
clearable
|
|
||||||
class="filter-select"
|
|
||||||
>
|
|
||||||
<el-option label="Config" value="config" />
|
|
||||||
<el-option label="Store" value="store" />
|
|
||||||
</el-select>
|
|
||||||
<el-select
|
|
||||||
v-model="filterType"
|
|
||||||
placeholder="Type"
|
|
||||||
clearable
|
|
||||||
class="filter-select"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="type in availableTypes"
|
|
||||||
:key="type"
|
|
||||||
:label="type.toUpperCase()"
|
|
||||||
:value="type"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchText"
|
v-model="searchText"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
@@ -80,18 +58,6 @@
|
|||||||
<el-tooltip content="Refresh" placement="top">
|
<el-tooltip content="Refresh" placement="top">
|
||||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-tooltip
|
|
||||||
v-if="storeEnabled"
|
|
||||||
content="Add new proxy"
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:icon="Plus"
|
|
||||||
circle
|
|
||||||
@click="handleCreate"
|
|
||||||
/>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -102,46 +68,10 @@
|
|||||||
v-for="proxy in filteredStatus"
|
v-for="proxy in filteredStatus"
|
||||||
:key="proxy.name"
|
:key="proxy.name"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
@edit="handleEdit"
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loading" class="empty-state">
|
<div v-else-if="!loading" class="empty-state">
|
||||||
<div class="empty-content">
|
<el-empty description="No proxies found" />
|
||||||
<div class="empty-icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 64 64"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="8"
|
|
||||||
y="16"
|
|
||||||
width="48"
|
|
||||||
height="32"
|
|
||||||
rx="4"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
<circle cx="20" cy="32" r="4" fill="currentColor" />
|
|
||||||
<circle cx="32" cy="32" r="4" fill="currentColor" />
|
|
||||||
<circle cx="44" cy="32" r="4" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="empty-text">No proxies configured</p>
|
|
||||||
<p class="empty-hint">
|
|
||||||
Add proxies in your configuration file or use Store to create
|
|
||||||
dynamic proxies
|
|
||||||
</p>
|
|
||||||
<el-button
|
|
||||||
v-if="storeEnabled"
|
|
||||||
type="primary"
|
|
||||||
:icon="Plus"
|
|
||||||
@click="handleCreate"
|
|
||||||
>
|
|
||||||
Create First Proxy
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -160,9 +90,7 @@
|
|||||||
v-for="(count, type) in proxyTypeCounts"
|
v-for="(count, type) in proxyTypeCounts"
|
||||||
:key="type"
|
:key="type"
|
||||||
class="proxy-type-item"
|
class="proxy-type-item"
|
||||||
:class="{ active: filterType === type }"
|
|
||||||
v-show="count > 0"
|
v-show="count > 0"
|
||||||
@click="toggleTypeFilter(String(type))"
|
|
||||||
>
|
>
|
||||||
<div class="proxy-type-name">
|
<div class="proxy-type-name">
|
||||||
{{ String(type).toUpperCase() }}
|
{{ String(type).toUpperCase() }}
|
||||||
@@ -197,139 +125,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Store Status Card -->
|
|
||||||
<el-card class="store-status-card" shadow="hover">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">Store</span>
|
|
||||||
<el-tag
|
|
||||||
size="small"
|
|
||||||
:type="storeEnabled ? 'success' : 'info'"
|
|
||||||
effect="plain"
|
|
||||||
>
|
|
||||||
{{ storeEnabled ? 'Enabled' : 'Disabled' }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="store-info">
|
|
||||||
<template v-if="storeEnabled">
|
|
||||||
<div class="store-stat">
|
|
||||||
<span class="store-stat-label">Store Proxies</span>
|
|
||||||
<span class="store-stat-value">{{ storeProxies.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="store-stat">
|
|
||||||
<span class="store-stat-label">Store Visitors</span>
|
|
||||||
<span class="store-stat-value">{{ storeVisitors.length }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="store-hint">
|
|
||||||
Proxies from Store are marked with a purple indicator
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p class="store-disabled-text">
|
|
||||||
Enable Store in your configuration to dynamically manage proxies
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- Store Visitors Section -->
|
|
||||||
<el-row v-if="storeEnabled && storeVisitors.length > 0" :gutter="20">
|
|
||||||
<el-col :span="24">
|
|
||||||
<el-card class="visitors-card" shadow="hover">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<span class="card-title">Store Visitors</span>
|
|
||||||
<el-tag size="small" type="info">{{ storeVisitors.length }} visitors</el-tag>
|
|
||||||
</div>
|
|
||||||
<el-tooltip content="Add new visitor" placement="top">
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:icon="Plus"
|
|
||||||
circle
|
|
||||||
@click="handleCreateVisitor"
|
|
||||||
/>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="visitor-list">
|
|
||||||
<div
|
|
||||||
v-for="visitor in storeVisitors"
|
|
||||||
:key="visitor.name"
|
|
||||||
class="visitor-card"
|
|
||||||
>
|
|
||||||
<div class="visitor-card-header">
|
|
||||||
<div class="visitor-info">
|
|
||||||
<span class="visitor-name">{{ visitor.name }}</span>
|
|
||||||
<el-tag size="small" type="info">{{ visitor.type.toUpperCase() }}</el-tag>
|
|
||||||
</div>
|
|
||||||
<div class="visitor-actions">
|
|
||||||
<el-button size="small" @click="handleEditVisitor(visitor)">
|
|
||||||
Edit
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
@click="handleDeleteVisitor(visitor.name)"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="visitor-card-body">
|
|
||||||
<span v-if="visitor.config?.serverName">
|
|
||||||
Server: {{ visitor.config.serverName }}
|
|
||||||
</span>
|
|
||||||
<span v-if="visitor.config?.bindAddr || visitor.config?.bindPort">
|
|
||||||
Bind: {{ visitor.config.bindAddr || '127.0.0.1' }}:{{ visitor.config.bindPort }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
import { getStatus } from '../api/frpc'
|
||||||
import {
|
import type { ProxyStatus } from '../types/proxy'
|
||||||
getStatus,
|
|
||||||
listStoreProxies,
|
|
||||||
deleteStoreProxy,
|
|
||||||
listStoreVisitors,
|
|
||||||
deleteStoreVisitor,
|
|
||||||
} from '../api/frpc'
|
|
||||||
import type {
|
|
||||||
ProxyStatus,
|
|
||||||
StoreProxyConfig,
|
|
||||||
StoreVisitorConfig,
|
|
||||||
} from '../types/proxy'
|
|
||||||
import StatCard from '../components/StatCard.vue'
|
import StatCard from '../components/StatCard.vue'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const status = ref<ProxyStatus[]>([])
|
const status = ref<ProxyStatus[]>([])
|
||||||
const storeProxies = ref<StoreProxyConfig[]>([])
|
|
||||||
const storeVisitors = ref<StoreVisitorConfig[]>([])
|
|
||||||
const storeEnabled = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const filterSource = ref('')
|
|
||||||
const filterType = ref('')
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const total = status.value.length
|
const total = status.value.length
|
||||||
const running = status.value.filter((p) => p.status === 'running').length
|
const running = status.value.filter((p) => p.status === 'running').length
|
||||||
@@ -350,164 +163,41 @@ const hasActiveProxies = computed(() => {
|
|||||||
return status.value.length > 0
|
return status.value.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const availableTypes = computed(() => {
|
|
||||||
const types = new Set<string>()
|
|
||||||
status.value.forEach((p) => types.add(p.type))
|
|
||||||
return Array.from(types).sort()
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredStatus = computed(() => {
|
const filteredStatus = computed(() => {
|
||||||
let result = status.value
|
if (!searchText.value) {
|
||||||
|
return status.value
|
||||||
if (filterSource.value) {
|
|
||||||
if (filterSource.value === 'store') {
|
|
||||||
result = result.filter((p) => p.source === 'store')
|
|
||||||
} else {
|
|
||||||
result = result.filter((p) => !p.source || p.source !== 'store')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const search = searchText.value.toLowerCase()
|
||||||
if (filterType.value) {
|
return status.value.filter(
|
||||||
result = result.filter((p) => p.type === filterType.value)
|
(p) =>
|
||||||
}
|
p.name.toLowerCase().includes(search) ||
|
||||||
|
p.type.toLowerCase().includes(search) ||
|
||||||
if (searchText.value) {
|
p.local_addr.toLowerCase().includes(search) ||
|
||||||
const search = searchText.value.toLowerCase()
|
p.remote_addr.toLowerCase().includes(search),
|
||||||
result = result.filter(
|
)
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(search) ||
|
|
||||||
p.type.toLowerCase().includes(search) ||
|
|
||||||
p.local_addr.toLowerCase().includes(search) ||
|
|
||||||
p.remote_addr.toLowerCase().includes(search),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const toggleTypeFilter = (type: string) => {
|
|
||||||
filterType.value = filterType.value === type ? '' : type
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
try {
|
|
||||||
const json = await getStatus()
|
|
||||||
const list: ProxyStatus[] = []
|
|
||||||
for (const key in json) {
|
|
||||||
for (const ps of json[key]) {
|
|
||||||
list.push(ps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status.value = list
|
|
||||||
} catch (err: any) {
|
|
||||||
ElMessage.error('Failed to get status: ' + err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStoreProxies = async () => {
|
|
||||||
try {
|
|
||||||
const res = await listStoreProxies()
|
|
||||||
storeProxies.value = res.proxies || []
|
|
||||||
storeEnabled.value = true
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.status === 404) {
|
|
||||||
storeEnabled.value = false
|
|
||||||
storeProxies.value = []
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch store proxies:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStoreVisitors = async () => {
|
|
||||||
try {
|
|
||||||
const res = await listStoreVisitors()
|
|
||||||
storeVisitors.value = res.visitors || []
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.status === 404) {
|
|
||||||
storeVisitors.value = []
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch store visitors:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await fetchStoreProxies()
|
const json = await getStatus()
|
||||||
await fetchStoreVisitors()
|
status.value = []
|
||||||
await fetchStatus()
|
for (const key in json) {
|
||||||
|
for (const ps of json[key]) {
|
||||||
|
status.value.push(ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage({
|
||||||
|
showClose: true,
|
||||||
|
message: 'Get status info from frpc failed! ' + err.message,
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
router.push('/proxies/create')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (proxy: ProxyStatus) => {
|
|
||||||
if (proxy.source !== 'store') return
|
|
||||||
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = (proxy: ProxyStatus) => {
|
|
||||||
if (proxy.source !== 'store') return
|
|
||||||
|
|
||||||
ElMessageBox.confirm(
|
|
||||||
`Are you sure you want to delete "${proxy.name}"? This action cannot be undone.`,
|
|
||||||
'Delete Proxy',
|
|
||||||
{
|
|
||||||
confirmButtonText: 'Delete',
|
|
||||||
cancelButtonText: 'Cancel',
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonClass: 'el-button--danger',
|
|
||||||
},
|
|
||||||
).then(async () => {
|
|
||||||
try {
|
|
||||||
await deleteStoreProxy(proxy.name)
|
|
||||||
ElMessage.success('Proxy deleted')
|
|
||||||
fetchData()
|
|
||||||
} catch (err: any) {
|
|
||||||
ElMessage.error('Delete failed: ' + err.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateVisitor = () => {
|
|
||||||
router.push('/visitors/create')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditVisitor = (visitor: StoreVisitorConfig) => {
|
|
||||||
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteVisitor = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`Are you sure you want to delete visitor "${name}"? This action cannot be undone.`,
|
|
||||||
'Delete Visitor',
|
|
||||||
{
|
|
||||||
confirmButtonText: 'Delete',
|
|
||||||
cancelButtonText: 'Cancel',
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonClass: 'el-button--danger',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await deleteStoreVisitor(name)
|
|
||||||
ElMessage.success('Visitor deleted')
|
|
||||||
fetchData()
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err !== 'cancel') {
|
|
||||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
fetchData()
|
fetchData()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -532,22 +222,19 @@ fetchData()
|
|||||||
|
|
||||||
.proxy-list-card,
|
.proxy-list-card,
|
||||||
.types-card,
|
.types-card,
|
||||||
.status-summary-card,
|
.status-summary-card {
|
||||||
.store-status-card {
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e4e7ed;
|
border: 1px solid #e4e7ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .proxy-list-card,
|
html.dark .proxy-list-card,
|
||||||
html.dark .types-card,
|
html.dark .types-card,
|
||||||
html.dark .status-summary-card,
|
html.dark .status-summary-card {
|
||||||
html.dark .store-status-card {
|
|
||||||
border-color: #3a3d5c;
|
border-color: #3a3d5c;
|
||||||
background: #27293d;
|
background: #27293d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-summary-card,
|
.status-summary-card {
|
||||||
.store-status-card {
|
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +255,7 @@ html.dark .store-status-card {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -581,12 +268,8 @@ html.dark .card-title {
|
|||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 180px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-list-content {
|
.proxy-list-content {
|
||||||
@@ -599,45 +282,8 @@ html.dark .card-title {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 48px 24px;
|
padding: 40px 0;
|
||||||
}
|
|
||||||
|
|
||||||
.empty-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .empty-icon {
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #606266;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .empty-text {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #909399;
|
|
||||||
margin: 0 0 20px;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Proxy Types Grid */
|
/* Proxy Types Grid */
|
||||||
@@ -657,7 +303,6 @@ html.dark .empty-text {
|
|||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-type-item:hover {
|
.proxy-type-item:hover {
|
||||||
@@ -665,19 +310,6 @@ html.dark .empty-text {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-type-item.active {
|
|
||||||
background: var(--el-color-primary-light-8);
|
|
||||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-type-item.active .proxy-type-name {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-type-item.active .proxy-type-count {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .proxy-type-item {
|
html.dark .proxy-type-item {
|
||||||
background: #1e1e2d;
|
background: #1e1e2d;
|
||||||
}
|
}
|
||||||
@@ -686,11 +318,6 @@ html.dark .proxy-type-item:hover {
|
|||||||
background: #2a2a3c;
|
background: #2a2a3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .proxy-type-item.active {
|
|
||||||
background: var(--el-color-primary-dark-2);
|
|
||||||
box-shadow: 0 0 0 2px var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-type-name {
|
.proxy-type-name {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
@@ -783,150 +410,6 @@ html.dark .status-item:hover {
|
|||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Store Status Card */
|
|
||||||
.store-info {
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-stat {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(102, 126, 234, 0.08) 0%,
|
|
||||||
rgba(118, 75, 162, 0.08) 100%
|
|
||||||
);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .store-stat {
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(129, 140, 248, 0.12) 0%,
|
|
||||||
rgba(167, 139, 250, 0.12) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-stat-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #606266;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .store-stat-label {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-stat-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .store-stat-value {
|
|
||||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #909399;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-disabled-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #909399;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Visitors Card */
|
|
||||||
.visitors-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e4e7ed;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .visitors-card {
|
|
||||||
border-color: #3a3d5c;
|
|
||||||
background: #27293d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-card {
|
|
||||||
padding: 16px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-card:hover {
|
|
||||||
background: #f0f2f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .visitor-card {
|
|
||||||
background: #1e1e2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .visitor-card:hover {
|
|
||||||
background: #2a2a3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-name {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .visitor-name {
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-card-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #606266;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .visitor-card-body {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-header {
|
.card-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -952,8 +435,7 @@ html.dark .visitor-card-body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
.status-summary-card,
|
.status-summary-card {
|
||||||
.store-status-card {
|
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,518 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="visitor-edit-page">
|
|
||||||
<!-- Breadcrumb -->
|
|
||||||
<nav class="breadcrumb">
|
|
||||||
<a class="breadcrumb-link" @click="goBack">
|
|
||||||
<el-icon><ArrowLeft /></el-icon>
|
|
||||||
</a>
|
|
||||||
<router-link to="/" class="breadcrumb-item">Overview</router-link>
|
|
||||||
<span class="breadcrumb-separator">/</span>
|
|
||||||
<span class="breadcrumb-current">{{ isEditing ? 'Edit Visitor' : 'Create Visitor' }}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div v-loading="pageLoading" class="edit-content">
|
|
||||||
<el-form
|
|
||||||
ref="formRef"
|
|
||||||
:model="form"
|
|
||||||
:rules="formRules"
|
|
||||||
label-position="top"
|
|
||||||
@submit.prevent
|
|
||||||
>
|
|
||||||
<!-- Header Card -->
|
|
||||||
<div class="form-card header-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="field-row three-col">
|
|
||||||
<el-form-item label="Name" prop="name" class="field-grow">
|
|
||||||
<el-input
|
|
||||||
v-model="form.name"
|
|
||||||
:disabled="isEditing"
|
|
||||||
placeholder="my-visitor"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Type" prop="type">
|
|
||||||
<el-select
|
|
||||||
v-model="form.type"
|
|
||||||
:disabled="isEditing"
|
|
||||||
:fit-input-width="false"
|
|
||||||
popper-class="visitor-type-dropdown"
|
|
||||||
class="type-select"
|
|
||||||
>
|
|
||||||
<el-option value="stcp" label="STCP">
|
|
||||||
<div class="type-option">
|
|
||||||
<span class="type-tag-inline type-stcp">STCP</span>
|
|
||||||
<span class="type-desc">Secure TCP Visitor</span>
|
|
||||||
</div>
|
|
||||||
</el-option>
|
|
||||||
<el-option value="sudp" label="SUDP">
|
|
||||||
<div class="type-option">
|
|
||||||
<span class="type-tag-inline type-sudp">SUDP</span>
|
|
||||||
<span class="type-desc">Secure UDP Visitor</span>
|
|
||||||
</div>
|
|
||||||
</el-option>
|
|
||||||
<el-option value="xtcp" label="XTCP">
|
|
||||||
<div class="type-option">
|
|
||||||
<span class="type-tag-inline type-xtcp">XTCP</span>
|
|
||||||
<span class="type-desc">P2P (NAT traversal)</span>
|
|
||||||
</div>
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Enabled">
|
|
||||||
<el-switch v-model="form.enabled" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection -->
|
|
||||||
<div class="form-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Connection</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Server Name" prop="serverName">
|
|
||||||
<el-input v-model="form.serverName" placeholder="Name of the proxy to visit" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Server User">
|
|
||||||
<el-input v-model="form.serverUser" placeholder="Leave empty for same user" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="Secret Key">
|
|
||||||
<el-input v-model="form.secretKey" type="password" show-password placeholder="Shared secret" />
|
|
||||||
</el-form-item>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Bind Address">
|
|
||||||
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Bind Port" prop="bindPort">
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.bindPort"
|
|
||||||
:min="1"
|
|
||||||
:max="65535"
|
|
||||||
controls-position="right"
|
|
||||||
class="full-width"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transport Options (collapsible) -->
|
|
||||||
<div class="form-card collapsible-card">
|
|
||||||
<div class="card-header clickable" @click="transportExpanded = !transportExpanded">
|
|
||||||
<h3 class="card-title">Transport Options</h3>
|
|
||||||
<el-icon class="collapse-icon" :class="{ expanded: transportExpanded }"><ArrowDown /></el-icon>
|
|
||||||
</div>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="transportExpanded" class="card-body">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Use Encryption">
|
|
||||||
<el-switch v-model="form.useEncryption" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Use Compression">
|
|
||||||
<el-switch v-model="form.useCompression" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- XTCP Options (collapsible, xtcp only) -->
|
|
||||||
<template v-if="form.type === 'xtcp'">
|
|
||||||
<div class="form-card collapsible-card">
|
|
||||||
<div class="card-header clickable" @click="xtcpExpanded = !xtcpExpanded">
|
|
||||||
<h3 class="card-title">XTCP Options</h3>
|
|
||||||
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"><ArrowDown /></el-icon>
|
|
||||||
</div>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="xtcpExpanded" class="card-body">
|
|
||||||
<el-form-item label="Protocol">
|
|
||||||
<el-select v-model="form.protocol" class="full-width">
|
|
||||||
<el-option value="quic" label="QUIC" />
|
|
||||||
<el-option value="kcp" label="KCP" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Keep Tunnel Open">
|
|
||||||
<el-switch v-model="form.keepTunnelOpen" />
|
|
||||||
</el-form-item>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Max Retries per Hour">
|
|
||||||
<el-input-number v-model="form.maxRetriesAnHour" :min="0" controls-position="right" class="full-width" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Min Retry Interval (s)">
|
|
||||||
<el-input-number v-model="form.minRetryInterval" :min="0" controls-position="right" class="full-width" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Fallback To">
|
|
||||||
<el-input v-model="form.fallbackTo" placeholder="Fallback visitor name" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Fallback Timeout (ms)">
|
|
||||||
<el-input-number v-model="form.fallbackTimeoutMs" :min="0" controls-position="right" class="full-width" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NAT Traversal (collapsible, xtcp only) -->
|
|
||||||
<div class="form-card collapsible-card">
|
|
||||||
<div class="card-header clickable" @click="natExpanded = !natExpanded">
|
|
||||||
<h3 class="card-title">NAT Traversal</h3>
|
|
||||||
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"><ArrowDown /></el-icon>
|
|
||||||
</div>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="natExpanded" class="card-body">
|
|
||||||
<el-form-item label="Disable Assisted Addresses">
|
|
||||||
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
|
|
||||||
<div class="form-tip">Only use STUN-discovered public addresses</div>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sticky Footer -->
|
|
||||||
<div class="sticky-footer">
|
|
||||||
<div class="footer-content">
|
|
||||||
<el-button @click="goBack">Cancel</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
|
||||||
{{ isEditing ? 'Update' : 'Create' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
|
||||||
import {
|
|
||||||
type VisitorFormData,
|
|
||||||
createDefaultVisitorForm,
|
|
||||||
formToStoreVisitor,
|
|
||||||
storeVisitorToForm,
|
|
||||||
} from '../types/proxy'
|
|
||||||
import {
|
|
||||||
getStoreVisitor,
|
|
||||||
createStoreVisitor,
|
|
||||||
updateStoreVisitor,
|
|
||||||
} from '../api/frpc'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const isEditing = computed(() => !!route.params.name)
|
|
||||||
const pageLoading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
const form = ref<VisitorFormData>(createDefaultVisitorForm())
|
|
||||||
|
|
||||||
const transportExpanded = ref(false)
|
|
||||||
const xtcpExpanded = ref(false)
|
|
||||||
const natExpanded = ref(false)
|
|
||||||
|
|
||||||
const formRules: FormRules = {
|
|
||||||
name: [
|
|
||||||
{ required: true, message: 'Name is required', trigger: 'blur' },
|
|
||||||
{ min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
type: [{ required: true, message: 'Type is required', trigger: 'change' }],
|
|
||||||
serverName: [
|
|
||||||
{ required: true, message: 'Server name is required', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
bindPort: [
|
|
||||||
{ required: true, message: 'Bind port is required', trigger: 'blur' },
|
|
||||||
{ type: 'number', min: 1, message: 'Port must be greater than 0', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadVisitor = async () => {
|
|
||||||
const name = route.params.name as string
|
|
||||||
if (!name) return
|
|
||||||
|
|
||||||
pageLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getStoreVisitor(name)
|
|
||||||
form.value = storeVisitorToForm(res)
|
|
||||||
} catch (err: any) {
|
|
||||||
ElMessage.error('Failed to load visitor: ' + err.message)
|
|
||||||
router.push('/')
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!formRef.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await formRef.value.validate()
|
|
||||||
} catch {
|
|
||||||
ElMessage.warning('Please fix the form errors')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const data = formToStoreVisitor(form.value)
|
|
||||||
if (isEditing.value) {
|
|
||||||
await updateStoreVisitor(form.value.name, data)
|
|
||||||
ElMessage.success('Visitor updated')
|
|
||||||
} else {
|
|
||||||
await createStoreVisitor(data)
|
|
||||||
ElMessage.success('Visitor created')
|
|
||||||
}
|
|
||||||
router.push('/')
|
|
||||||
} catch (err: any) {
|
|
||||||
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (isEditing.value) {
|
|
||||||
loadVisitor()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.visitor-edit-page {
|
|
||||||
padding-bottom: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Breadcrumb */
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-item {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-item:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-separator {
|
|
||||||
color: var(--el-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-current {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Cards */
|
|
||||||
.form-card {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border: 1px solid var(--header-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .form-card {
|
|
||||||
border-color: #3a3d5c;
|
|
||||||
background: #27293d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 1px solid var(--header-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .card-header {
|
|
||||||
border-bottom-color: #3a3d5c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header.clickable:hover {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-card .card-header {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-card .card-body {
|
|
||||||
border-top: 1px solid var(--header-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .collapsible-card .card-body {
|
|
||||||
border-top-color: #3a3d5c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-icon {
|
|
||||||
transition: transform 0.3s;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-icon.expanded {
|
|
||||||
transform: rotate(-180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Field Rows */
|
|
||||||
.field-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row.two-col {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row.three-col {
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-grow {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-width {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-select {
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag-inline {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag-inline.type-stcp,
|
|
||||||
.type-tag-inline.type-sudp,
|
|
||||||
.type-tag-inline.type-xtcp {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: #8b5cf6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sticky Footer */
|
|
||||||
.sticky-footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 99;
|
|
||||||
background: var(--header-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border-top: 1px solid var(--header-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 16px 40px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.field-row.two-col,
|
|
||||||
.field-row.three-col {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
padding: 12px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.visitor-type-dropdown {
|
|
||||||
min-width: 300px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-type-dropdown .el-select-dropdown__item {
|
|
||||||
height: auto;
|
|
||||||
padding: 8px 16px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
20
web/frps/package-lock.json
generated
20
web/frps/package-lock.json
generated
@@ -1556,6 +1556,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1566,6 +1567,7 @@
|
|||||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1626,6 +1628,7 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -2038,6 +2041,7 @@
|
|||||||
"integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==",
|
"integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vueuse/metadata": "14.1.0",
|
"@vueuse/metadata": "14.1.0",
|
||||||
@@ -2079,6 +2083,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3039,6 +3044,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -3095,6 +3101,7 @@
|
|||||||
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -3139,6 +3146,7 @@
|
|||||||
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
|
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.4.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"globals": "^13.24.0",
|
"globals": "^13.24.0",
|
||||||
@@ -4513,13 +4521,15 @@
|
|||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -5145,6 +5155,7 @@
|
|||||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -5354,6 +5365,7 @@
|
|||||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -5795,6 +5807,7 @@
|
|||||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
@@ -5922,6 +5935,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6369,6 +6383,7 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6463,6 +6478,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.26",
|
"@vue/compiler-dom": "3.5.26",
|
||||||
"@vue/compiler-sfc": "3.5.26",
|
"@vue/compiler-sfc": "3.5.26",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
<span v-if="client.hostname" class="hostname-badge">{{
|
<span v-if="client.hostname" class="hostname-badge">{{
|
||||||
client.hostname
|
client.hostname
|
||||||
}}</span>
|
}}</span>
|
||||||
<el-tag v-if="client.version" size="small" type="success">v{{ client.version }}</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export interface ClientInfoData {
|
|||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
runID: string
|
runID: string
|
||||||
version?: string
|
|
||||||
hostname: string
|
hostname: string
|
||||||
clientIP?: string
|
clientIP?: string
|
||||||
metas?: Record<string, string>
|
metas?: Record<string, string>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface ProxyStatsInfo {
|
|||||||
conf: any
|
conf: any
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
|
clientVersion: string
|
||||||
todayTrafficIn: number
|
todayTrafficIn: number
|
||||||
todayTrafficOut: number
|
todayTrafficOut: number
|
||||||
curConns: number
|
curConns: number
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export class Client {
|
|||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
runID: string
|
runID: string
|
||||||
version: string
|
|
||||||
hostname: string
|
hostname: string
|
||||||
ip: string
|
ip: string
|
||||||
metas: Map<string, string>
|
metas: Map<string, string>
|
||||||
@@ -20,7 +19,6 @@ export class Client {
|
|||||||
this.user = data.user
|
this.user = data.user
|
||||||
this.clientID = data.clientID
|
this.clientID = data.clientID
|
||||||
this.runID = data.runID
|
this.runID = data.runID
|
||||||
this.version = data.version || ''
|
|
||||||
this.hostname = data.hostname
|
this.hostname = data.hostname
|
||||||
this.ip = data.clientIP || ''
|
this.ip = data.clientIP || ''
|
||||||
this.metas = new Map<string, string>()
|
this.metas = new Map<string, string>()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class BaseProxy {
|
|||||||
status: string
|
status: string
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
|
clientVersion: string
|
||||||
addr: string
|
addr: string
|
||||||
port: number
|
port: number
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ class BaseProxy {
|
|||||||
this.status = proxyStats.status
|
this.status = proxyStats.status
|
||||||
this.user = proxyStats.user || ''
|
this.user = proxyStats.user || ''
|
||||||
this.clientID = proxyStats.clientID || ''
|
this.clientID = proxyStats.clientID || ''
|
||||||
|
this.clientVersion = proxyStats.clientVersion
|
||||||
|
|
||||||
this.addr = ''
|
this.addr = ''
|
||||||
this.port = 0
|
this.port = 0
|
||||||
|
|||||||
@@ -22,10 +22,7 @@
|
|||||||
{{ client.displayName.charAt(0).toUpperCase() }}
|
{{ client.displayName.charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="client-info">
|
<div class="client-info">
|
||||||
<div class="client-name-row">
|
<h1 class="client-name">{{ client.displayName }}</h1>
|
||||||
<h1 class="client-name">{{ client.displayName }}</h1>
|
|
||||||
<el-tag v-if="client.version" size="small" type="success">v{{ client.version }}</el-tag>
|
|
||||||
</div>
|
|
||||||
<div class="client-meta">
|
<div class="client-meta">
|
||||||
<span v-if="client.ip" class="meta-item">{{
|
<span v-if="client.ip" class="meta-item">{{
|
||||||
client.ip
|
client.ip
|
||||||
@@ -357,18 +354,11 @@ onMounted(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-name {
|
.client-name {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0 0 4px 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user