Compare commits

..

3 Commits

Author SHA1 Message Date
qaq a155bc64c0 增加稳定版arm支持 2025-11-25 09:43:32 +08:00
qaq 62bd8ea469 修复稳定版面板显示屏蔽协议状态不一致问题 2025-11-25 09:21:36 +08:00
qaq f3a9684be4 修复稳定版面板显示屏蔽协议状态不一致问题 2025-11-25 09:18:40 +08:00
663 changed files with 22707 additions and 104508 deletions
@@ -1,62 +0,0 @@
# 功能请求:在规则页面显示隧道倍率
## 问题描述
当前规则(Forward)页面在列表中显示隧道名称,但**不显示隧道的流量倍率(trafficRatio)**。管理员在管理规则时无法快速查看该规则所使用的隧道倍率信息,需要跳转到隧道页面才能查看。
## 期望行为
在规则列表页面中,在隧道名称旁边或单独列显示该隧道的流量倍率(例如:`1x`, `0.5x`, `2x`)。
## 建议实现位置
### 前端修改
1. **`vite-frontend/src/pages/forward.tsx`**
- 在 `Forward` interface 中添加 `tunnelTrafficRatio?: number` 字段
- 在表格列中添加倍率显示(可以在隧道名称 Chip 旁边或单独一列)
- 从 `userTunnel` 或 `getTunnelList` API 获取隧道倍率信息
2. **显示格式建议**
```tsx
<Chip className="...">
{forward.tunnelName} ({forward.tunnelTrafficRatio}x)
</Chip>
```
或者单独一列:
```tsx
<TableCell>
{forward.tunnelTrafficRatio}x
</TableCell>
```
### 后端修改
1. **`go-backend/internal/http/handler/handler.go`**
- 在 `forwardList` 接口返回中添加隧道的 `trafficRatio` 字段
- 需要在查询 Forward 时 JOIN Tunnel 表获取倍率信息
2. **或者在前端加载规则后,批量获取隧道信息**
- 调用 `getTunnelList` 获取所有隧道信息
- 根据 `tunnelId` 匹配倍率
## 相关文件
- 前端:`vite-frontend/src/pages/forward.tsx`
- 前端类型:`vite-frontend/src/api/types.ts`
- 后端:`go-backend/internal/http/handler/handler.go`
- 隧道类型定义:`vite-frontend/src/api/types.ts` (TunnelApiItem)
## 优先级
中等 - 不影响核心功能,但能提升管理效率
## 截图参考
隧道页面已显示倍率:
- 位置:隧道卡片统计信息区域
- 显示格式:`流量倍率 {trafficRatio}x`
---
**Labels**: `enhancement`, `frontend`, `backend`, `ui/ux`
-110
View File
@@ -1,110 +0,0 @@
name: CI Build Check
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
frontend:
name: Build Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: vite-frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
backend:
name: Build Go Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: go-backend
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build -v ./...
backend-postgres-contract:
name: Go Backend PostgreSQL Contract
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: flux_test
POSTGRES_PASSWORD: flux_test_pass
POSTGRES_DB: flux_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U flux_test -d flux_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: go-backend
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
run: go mod download
- name: Run PostgreSQL contract test
env:
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable'
run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1
agent:
name: Build Agent
runs-on: ubuntu-latest
defaults:
run:
working-directory: go-gost
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build -v .
-43
View File
@@ -1,43 +0,0 @@
name: Deploy Docs
on:
push:
branches:
- main
- master
- beta
paths:
- 'doc/**'
- 'mkdocs.yml'
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Install MkDocs and Material Theme
run: pip install mkdocs-material
- name: Build and Deploy
run: mkdocs gh-deploy --force
+141 -245
View File
@@ -1,71 +1,55 @@
name: Build and Push Images
name: Build and Push Images Based on Version
# 在这里定义统一版本号
env:
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
REGISTRY: ghcr.io
VERSION: "1.4.3"
on:
push:
tags:
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
branches:
- main
- beta
jobs:
check-version:
name: Check Version and Decide Build
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
should_build: ${{ steps.version.outputs.should_build }}
should_build_gost: ${{ steps.version.outputs.should_build_gost }}
is_tag: ${{ steps.version.outputs.is_tag }}
image_owner: ${{ steps.version.outputs.image_owner }}
version: ${{ env.VERSION }}
should_build: ${{ steps.check-tag.outputs.should_build }}
should_build_gost: ${{ steps.check-tag.outputs.should_build_gost }}
tag_exists: ${{ steps.check-tag.outputs.tag_exists }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Determine version and build strategy
id: version
- name: Display version
run: |
# 镜像 owner 需要小写
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
echo "Current version: ${{ env.VERSION }}"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
# Tag 触发:直接使用 tag 名作为版本,全量构建
VERSION="${{ github.ref_name }}"
echo "🏷️ Tag trigger detected: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=true" >> $GITHUB_OUTPUT
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
else
# 分支触发:使用 env.VERSION,检查是否需要构建
VERSION="${{ env.VERSION }}"
echo "🌿 Branch trigger detected, using version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=false" >> $GITHUB_OUTPUT
# 检查 tag 是否已存在
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists"
echo "should_build=false" >> $GITHUB_OUTPUT
# 检查 go-gost 目录是否有变化
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
echo "✅ GOST files unchanged since tag"
echo "should_build_gost=false" >> $GITHUB_OUTPUT
else
echo "🔄 Detected changes in go-gost directory"
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
- name: Check if tag exists and detect changes
id: check-tag
run: |
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then
echo "Tag ${{ env.VERSION }} already exists"
echo "tag_exists=true" >> $GITHUB_OUTPUT
echo "should_build=false" >> $GITHUB_OUTPUT
# 检查从tag到现在,go-gost目录下的文件是否有变化
TAG_COMMIT=$(git rev-list -n 1 "${{ env.VERSION }}")
if git diff --quiet $TAG_COMMIT HEAD -- go-gost/; then
echo "✅ GOST files unchanged since tag"
echo "should_build_gost=false" >> $GITHUB_OUTPUT
else
echo "Tag $VERSION does not exist, will build all components"
echo "should_build=true" >> $GITHUB_OUTPUT
echo "🔄 GOST files changed since tag, will rebuild GOST binary"
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
else
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
echo "tag_exists=false" >> $GITHUB_OUTPUT
fi
build-gost:
@@ -74,49 +58,49 @@ jobs:
if: needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v4
with:
go-version: '1.23'
go-version: '1.21'
- name: Cache Go modules
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install UPX
run: |
wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
tar -xf upx-4.2.1-amd64_linux.tar.xz
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
rm -rf upx-4.2.1-amd64_linux*
- name: Build GOST binary (AMD64)
working-directory: ./go-gost
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
- name: Build GOST binary (ARM64)
working-directory: ./go-gost
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
- name: Compress with UPX
- name: Compress with UPX (AMD64)
working-directory: ./go-gost
run: |
upx --best --lzma gost-amd64
upx --best --lzma gost-arm64
- name: Generate SHA256 checksums
- name: Compress with UPX (ARM64)
working-directory: ./go-gost
run: |
sha256sum gost-amd64 > gost-amd64.sha256
sha256sum gost-arm64 > gost-arm64.sha256
upx --best --lzma gost-arm64
- name: Upload GOST AMD64 artifact
uses: actions/upload-artifact@v4
@@ -130,119 +114,91 @@ jobs:
name: gost-binary-arm64
path: ./go-gost/gost-arm64
- name: Upload GOST AMD64 checksum artifact
uses: actions/upload-artifact@v4
with:
name: gost-checksum-amd64
path: ./go-gost/gost-amd64.sha256
- name: Upload GOST ARM64 checksum artifact
uses: actions/upload-artifact@v4
with:
name: gost-checksum-arm64
path: ./go-gost/gost-arm64.sha256
build-vite:
name: Build & Push Vite Frontend
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare build args
run: |
VERSION="${{ needs.check-version.outputs.version }}"
{
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
echo "VITE_APP_VERSION=$VERSION"
} > ./vite-frontend/.env.production
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push Vite Docker images
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
-t bqlpfy/vite-frontend:latest \
-t bqlpfy/vite-frontend:${VERSION} \
./vite-frontend
build-go-backend:
name: Build & Push Go Backend
build-java:
name: Build & Push Spring Boot Backend
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v5
- name: Set up JDK and Maven
uses: actions/setup-java@v3
with:
go-version: '1.23'
java-version: 21
distribution: 'temurin'
- name: Cache Go dependencies
uses: actions/cache@v4
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-backend-${{ hashFiles('go-backend/go.sum') }}
restore-keys: ${{ runner.os }}-go-backend-
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2
- name: Download dependencies
working-directory: ./go-backend
run: go mod download
- name: Build Java JAR
working-directory: ./springboot-backend
run: mvn clean package -DskipTests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push Go backend Docker images
- name: Build and push Java Docker images
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
./go-backend
-t bqlpfy/springboot-backend:latest \
-t bqlpfy/springboot-backend:${VERSION} \
./springboot-backend
create-release:
name: Create Release (Tag Only)
needs: [check-version, build-gost, build-vite, build-go-backend]
if: needs.check-version.outputs.is_tag == 'true'
name: Create Release and Tag
needs: [check-version, build-gost, build-vite, build-java]
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
@@ -256,135 +212,60 @@ jobs:
name: gost-binary-arm64
path: ./artifacts/arm64
- name: Download GOST AMD64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-amd64
path: ./artifacts/
- name: Download GOST ARM64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-arm64
path: ./artifacts/
- name: Prepare release files
- name: Rename binaries
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
REPO="${{ github.repository }}"
# 移动二进制文件
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
# 复制并修改 docker-compose 文件
cp docker-compose-v4.yml ./artifacts/docker-compose-v4.yml
cp docker-compose-v6.yml ./artifacts/docker-compose-v6.yml
# 替换镜像地址为 GHCR
sed -i "s|image: .*flux-panel-backend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|image: .*vite-frontend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|image: .*flux-panel-backend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
sed -i "s|image: .*vite-frontend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
# 复制并修改安装脚本
cp install.sh ./artifacts/install.sh
cp panel_install.sh ./artifacts/panel_install.sh
# 替换仓库地址和版本号
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/install.sh
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/panel_install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
# 注入固定版本号,使从 Release 页下载的脚本只安装该版本
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
# 创建并推送 tag
git tag "${VERSION}" ${{ github.sha }}
git push origin "${VERSION}"
# 获取 commit 信息
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
COMMIT_DATE=$(git log -1 --pretty=format:"%ai")
# 创建 release
gh release create "${VERSION}" \
--title "Release ${VERSION}" \
--notes "## 📝 Release Information
- **Version**: ${VERSION}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
--notes "## 📝 Commit Information
- **Message**: ${COMMIT_MSG}
- **Author**: ${COMMIT_AUTHOR}
- **Date**: ${COMMIT_DATE}
- **Message**: ${COMMIT_MSG}
## 📦 Docker Images
\`\`\`bash
# Backend
docker pull ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}
# Frontend
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
\`\`\`
## 🚀 Quick Install
**Panel (安装此版本 ${VERSION}):**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
\`\`\`
**Node (安装此版本 ${VERSION}):**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`
**安装最新版:**
\`\`\`bash
# 面板端
curl -L https://raw.githubusercontent.com/${{ github.repository }}/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
# 节点端
curl -L https://raw.githubusercontent.com/${{ github.repository }}/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`" \
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})" \
--repo ${{ github.repository }}
# 上传所有文件到 release
echo "📤 上传 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "📤 上传 GOST 校验文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
echo "📤 上传安装脚本..."
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
gh release upload "${VERSION}" ./install.sh --clobber
gh release upload "${VERSION}" ./panel_install.sh --clobber
echo "📤 上传 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./artifacts/docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./artifacts/docker-compose-v6.yml --clobber
echo "✅ Release ${VERSION} 创建完成"
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
echo "✅ 所有文件已上传到 Release ${VERSION}"
update-release-gost:
name: Update GOST Binaries in Release
needs: [check-version, build-gost]
if: needs.check-version.outputs.is_tag == 'false' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
if: needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
@@ -398,18 +279,6 @@ jobs:
name: gost-binary-arm64
path: ./artifacts/arm64
- name: Download GOST AMD64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-amd64
path: ./artifacts/
- name: Download GOST ARM64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-arm64
path: ./artifacts/
- name: Rename binaries
run: |
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
@@ -420,15 +289,42 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "✅ GOST 二进制文件更新完成!"
echo "ℹ️ Docker 镜像未重新构建"
echo "📤 上传 GOST 校验文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
echo "✅ GOST 二进制文件更新完成"
update-release-files:
name: Update Release Files Only
needs: check-version
if: needs.check-version.outputs.tag_exists == 'true' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'false'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Update Release Files
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
echo "📤 更新 Release ${VERSION} 中的脚本和配置文件..."
# 上传安装脚本(覆盖)
echo "📤 更新安装脚本..."
gh release upload "${VERSION}" ./install.sh --clobber
gh release upload "${VERSION}" ./panel_install.sh --clobber
# 上传 Docker Compose 配置文件(覆盖)
echo "📤 更新 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
echo "✅ 文件更新完成!Release ${VERSION} 中的脚本和配置已更新"
echo "ℹ️ Docker 镜像和 GOST 二进制未重新构建"
-48
View File
@@ -1,48 +0,0 @@
name: Publish Skill to npm
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Get version from tag
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION=$(node -p "require('./skills/flvx-api/package.json').version")
else
VERSION="${GITHUB_REF#refs/tags/v}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Publishing skill version: $VERSION"
- name: Publish to npm
working-directory: skills/flvx-api
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
if: github.event_name == 'push'
uses: softprops/action-gh-release@v1
with:
name: Skill v${{ steps.version.outputs.version }}
generate_release_notes: true
files: skills/flvx-api/package.json
+1 -19
View File
@@ -62,12 +62,8 @@ go-gost/ss/
.classpath
.project
.settings/
# OpenCode session metadata
.entire/
bin/
tmp/
.worktrees/
*.swp
*.bak
@@ -180,8 +176,6 @@ build/
*.so
*.dylib
your_app.exe
go-backend/paneld
go-gost/gost
# Go 测试二进制文件
*.test
@@ -262,16 +256,4 @@ gitee/
*.cert
doraemon.jks
device.id
commit.sh
.opencode/
analysis/
sql/
!go-backend/internal/store/sqlite/sql/
!go-backend/internal/store/sqlite/sql/schema.sql
!go-backend/internal/store/sqlite/sql/data.sql
!go-backend/internal/store/postgres/sql/
!go-backend/internal/store/postgres/sql/schema.sql
!go-backend/internal/store/postgres/sql/data.sql
go-backend/gost.db-shm
.gitignore
go-backend/gost.db-wal
commit.sh
-62
View File
@@ -1,62 +0,0 @@
# AGENTS
FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
## Structure
| Dir | Role | Entry |
|-----|------|-------|
| `go-backend/` | Admin API (GORM + SQLite/PG, net/http) | `cmd/paneld/main.go` |
| `go-gost/` | Forwarding agent (forked GOST) | `main.go` |
| `go-gost/x/` | Protocol handlers/dialers/listeners (own module) | — |
| `vite-frontend/` | React dashboard (shadcn bridge + Tailwind v4) | `src/App.tsx` |
`go-gost/go.mod` uses `replace github.com/go-gost/x => ./x`.
## Commands
```bash
# Backend
(cd go-backend && go run ./cmd/paneld) # SERVER_ADDR defaults to :6365
(cd go-backend && make build)
(cd go-backend && go test ./...)
# Frontend
(cd vite-frontend && pnpm install)
(cd vite-frontend && pnpm run dev) # host 0.0.0.0:3000
(cd vite-frontend && pnpm run build) # tsc && vite build
(cd vite-frontend && pnpm run lint) # eslint --fix (no typecheck command)
# Agent
(cd go-gost && go run .)
```
## Conventions
- **Auth**: raw JWT in `Authorization` header — **no `Bearer` prefix** (both frontend and backend).
- **API envelope**: all responses `{code, msg, data, ts}` (code 0 = success).
- **Frontend UI**: import from `src/shadcn-bridge/heroui/*`, never `@heroui/*` or `@nextui-org/*`.
- **Tailwind theme**: `globals.css` must import `tailwind-theme.pcss` or semantic classes break.
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
## Anti-patterns
- Don't edit `install.sh` or `panel_install.sh` locally (CI overwrites on release).
- Don't edit `go-gost/x/internal/util/grpc/proto/*.pb.go` (generated).
- Don't add frontend tests (no Vitest/Jest configured).
- Don't reintroduce `@heroui/*` or `@nextui-org/*` packages.
## Testing
- Backend: `(cd go-backend && go test ./...)` — includes contract tests in `tests/contract/`.
- Frontend: no test infrastructure.
- CI runs one PostgreSQL contract test: env var `FLVX_POSTGRES_TEST_DSN`.
## Build quirks
- `vite-frontend` uses `rolldown-vite` (Rust bundler), not standard Vite.
- `vite.config.ts`: `minify: false`, `treeshake: false` (debugging mode).
- CI builds `go-gost` with `CGO_ENABLED=0` then compresses with UPX `--best --lzma`.
+201 -674
View File
@@ -1,674 +1,201 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer to sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
-19
View File
@@ -1,19 +0,0 @@
FLVX
Copyright 2026 Sagit-chu
This product includes software developed at
flux-panel (https://github.com/bqlpfy/flux-panel)
Copyright 2024-2026 bqlpfy
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
-------------------------------------------------------------------------
This project is a derivative work based on flux-panel.
Modifications and new components (backend, agents, frontend updates)
are licensed under the GNU General Public License v3.0 (GPLv3).
See the LICENSE file for the full GPLv3 text.
-------------------------------------------------------------------------
+37 -128
View File
@@ -1,8 +1,25 @@
# FLVX
# flux-panel转发面板 哆啦A梦转发面板
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
# 赞助商
<p align="center">
<a href="https://vps.town" style="margin: 0 20px; text-align:center;">
<img src="./doc/vpstown.png" width="300">
</a>
<a href="https://whmcs.as211392.com" style="margin: 0 20px; text-align:center;">
<img src="./doc/as211392.png" width="300">
</a>
</p>
使用1.x版本的请使用上面给出的脚本,不要使用最新的,最新的默认2.x
嫌弃麻烦或者想稳定的我不推荐你升级到2.x版本 目前还在beta阶段,很多问题。
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
---
## 特性
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
@@ -11,110 +28,32 @@
- 可针对 **指定用户的指定隧道进行限速** 设置
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
- 提供灵活的转发策略配置,适用于多种网络场景
- 面板分享,支持将节点分享给其他人,面板对接面板
- 支持分组权限管理,隧道分组、用户分组
- 支持批量功能,可以批量下发配置,启停等
- 支持隧道修改配置、转发修改隧道
## 部署流程
---
### Docker Compose部署
#### 快速部署(安装最新版)
面板端:
#### 快速部署
面板端(稳定版):
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端:
节点端(稳定版):
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### 安装特定版本
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
面板端(以 2.1.9-beta6 为例):
面板端(开发版):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(以 2.1.9-beta6 为例):
节点端(开发版):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### PostgreSQL 部署(Docker Compose)
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。
1) 在 `docker-compose` 同目录创建或修改 `.env`:
```bash
JWT_SECRET=replace_with_your_secret
BACKEND_PORT=6365
FRONTEND_PORT=6366
DB_TYPE=postgres
DATABASE_URL=postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable
POSTGRES_DB=flux_panel
POSTGRES_USER=flux_panel
POSTGRES_PASSWORD=replace_with_strong_password
```
> 📌 使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。
2) 启动服务:
```bash
docker compose up -d
```
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
#### 从 SQLite 迁移到 PostgreSQL
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
```bash
./panel_install.sh
# 选择 4. 迁移到 PostgreSQL
```
脚本会自动完成 SQLite 备份、PostgreSQL 启动、`pgloader` 导入、`.env` 中 `DB_TYPE`/`DATABASE_URL` 更新,并重启服务。
如果你希望手动迁移,以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`:
1) 停止服务并备份 SQLite 数据:
```bash
docker compose down
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
```
2) 仅启动 PostgreSQL:
```bash
docker compose up -d postgres
```
3) 使用 `pgloader` 迁移:
```bash
source .env
docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
```
4) 切换后端到 PostgreSQL 并启动:
```bash
source .env
export DB_TYPE=postgres
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
docker compose up -d
```
5) 迁移完成后,登录面板检查用户、隧道、转发、节点数据是否正确。
#### 默认管理员账号
- **账号**: admin_user
@@ -122,38 +61,6 @@ docker compose up -d
> ⚠️ 首次登录后请立即修改默认密码!
---
## Original Project
- **Name**: flux-panel
- **Source**: https://github.com/bqlpfy/flux-panel
- **License**: Apache License 2.0
## Modifications
This fork (FLVX) is no longer a light patch on top of the upstream project. It has been deeply reworked, with both backend and frontend rebuilt around a Go-based architecture.
### 1. Backend (Rewritten)
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) implementation.
- **Added**: A fully rewritten `go-backend/` service (Go), including updated data and API handling for panel management.
### 2. Frontend (Reworked)
- **Reworked**: `vite-frontend/` has been substantially rebuilt to match the new backend contract and current UI layer architecture.
- **Updated**: Dashboard pages/components and interaction flows for the current React/Vite stack.
### 3. Forwarding Stack (Modified)
- **Modified**: `go-gost/` forwarding agent wrapper.
- **Modified**: `go-gost/x/` local fork of `github.com/go-gost/x`.
### 4. Mobile Clients (Removed)
- **Removed**: `android-app/` source code.
- **Removed**: `ios-app/` source code.
### 5. Deployment & Project Infrastructure
- **Updated**: Docker deployment templates and installer output flow (IPv4/IPv6 compose variants).
- **Updated**: Release installation scripts (`install.sh`, `panel_install.sh`) and supporting automation.
- **Added/Updated**: Project-level engineering documentation (for example `AGENTS.md`).
---
## 免责声明
@@ -177,14 +84,16 @@ This fork (FLVX) is no longer a light patch on top of the upstream project. It h
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。
请务必在合法、合规、安全的前提下使用本项目。
---
## ⭐ 喝杯咖啡!(USDT)
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
[![Star History Chart](https://api.star-history.com/svg?repos=bqlpfy/flux-panel&type=Date)](https://www.star-history.com/#bqlpfy/flux-panel&Date)
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+1
View File
@@ -0,0 +1 @@
/build
+45
View File
@@ -0,0 +1,45 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.flux'
compileSdk 34
defaultConfig {
applicationId "com.flux"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.webkit:webkit:1.8.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Binary file not shown.
@@ -0,0 +1,20 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.flux",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.1",
"outputFile": "app-release.apk"
}
],
"elementType": "File"
}
@@ -0,0 +1,24 @@
package com.flux
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.flux", appContext.packageName)
}
}
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.flux" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Flux"
android:usesCleartextTraffic="true"
tools:targetApi="31" >
<activity
android:name="com.flux.MainActivity"
android:exported="true"
android:configChanges="uiMode|orientation|screenSize" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

@@ -0,0 +1,214 @@
package com.flux
import androidx.core.content.edit
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsetsController
import androidx.appcompat.app.AppCompatActivity
import android.webkit.*
import android.content.res.Configuration
import org.json.JSONArray
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.webView)
WebView.setWebContentsDebuggingEnabled(true)
setupWebView()
loadUrl()
setupSystemBars()
setupRootBackground()
}
private fun setupWebView() {
val settings = webView.settings
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.setSupportZoom(true)
settings.builtInZoomControls = true
settings.displayZoomControls = false
// 隐藏滚动条但不影响滚动
webView.isVerticalScrollBarEnabled = false
webView.isHorizontalScrollBarEnabled = false
webView.scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY
webView.overScrollMode = View.OVER_SCROLL_NEVER
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
webView.setBackgroundColor(if (isDarkTheme) Color.BLACK else Color.WHITE)
// 添加JavaScript接口
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun getPanelAddresses(callback: String) {
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
webView.post {
webView.evaluateJavascript("window.$callback($addresses);", null)
}
}
@android.webkit.JavascriptInterface
fun savePanelAddress(name: String, address: String) {
val json = JSONObject()
json.put("name", name)
json.put("address", address)
json.put("inx", false)
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
val jsonArray = JSONArray(addresses)
jsonArray.put(json)
sharedPrefs.edit {
putString("panel_addresses", jsonArray.toString())
}
webView.post {
webView.evaluateJavascript("window.setPanelAddresses(${jsonArray.toString()});", null)
}
}
@android.webkit.JavascriptInterface
fun setCurrentPanelAddress(name: String) {
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
val jsonArray = JSONArray(addresses)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
if (name == obj.getString("name")) {
obj.put("inx", true)
}else{
obj.put("inx", false)
}
}
sharedPrefs.edit {
putString("panel_addresses", jsonArray.toString())
}
webView.post {
webView.evaluateJavascript("window.setPanelAddresses(${jsonArray.toString()});", null)
}
}
@android.webkit.JavascriptInterface
fun deletePanelAddress(name: String) {
val jsonArray = JSONArray()
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
val jsonArraya = JSONArray(addresses)
for (i in 0 until jsonArraya.length()) {
val obj = jsonArraya.getJSONObject(i)
if (name != obj.getString("name")) {
jsonArray.put(obj)
}
}
sharedPrefs.edit {
putString("panel_addresses", jsonArray.toString())
}
webView.post {
webView.evaluateJavascript("window.setPanelAddresses(${jsonArray.toString()});", null)
}
}
}, "JsInterface")
}
private fun loadUrl() {
//webView.loadUrl("http://192.168.100.9:3000")
webView.loadUrl("file:///android_asset/index.html")
}
private fun setupSystemBars() {
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
if (isDarkTheme) {
window.statusBarColor = Color.BLACK
window.navigationBarColor = Color.BLACK
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
}
}
} else {
window.statusBarColor = Color.WHITE
window.navigationBarColor = Color.WHITE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
}
}
}
}
override fun onBackPressed() {
if (webView.canGoBack()) webView.goBack() else super.onBackPressed()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setupSystemBars()
setupWebViewBackground()
setupRootBackground()
}
private fun setupWebViewBackground() {
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
webView.setBackgroundColor(if (isDarkTheme) Color.BLACK else Color.WHITE)
}
private fun setupRootBackground() {
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
val rootView = findViewById<View>(android.R.id.content)
rootView.setBackgroundColor(if (isDarkTheme) Color.BLACK else Color.WHITE)
}
}
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:id="@+id/webView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.Flux" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Flux</string>
</resources>
@@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.Flux" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Theme.Flux" parent="Base.Theme.Flux" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.flux
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
}
+23
View File
@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+6
View File
@@ -0,0 +1,6 @@
#Wed Aug 20 09:15:27 CST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or 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
#
# https://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.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Flux"
include ':app'
-220
View File
@@ -1,220 +0,0 @@
# AI Skill 使用指南
让大模型直接操作 FLVX 面板的技能包。支持 OpenCode、OpenClaw、Claude Code 等工具。
## 安装
### 方式 1: npm (推荐)
```bash
npm install -g @flvx/skill-api
```
postinstall 脚本会自动链接到 `~/.agents/skills/flvx-api/`。
### 方式 2: 手动链接
```bash
# 从 FLVX 源码
cd /path/to/flvx
mkdir -p ~/.agents/skills
ln -sf $(pwd)/skills/flvx-api ~/.agents/skills/
# 或从 GitHub
git clone https://github.com/Sagit-chu/flvx.git
cd flvx
ln -sf $(pwd)/skills/flvx-api ~/.agents/skills/
```
## 配置
设置环境变量:
```bash
export FLVX_BASE_URL="https://your-panel.example.com"
export FLVX_USERNAME="admin"
export FLVX_PASSWORD="your-password"
```
或使用凭证文件:
```bash
mkdir -p ~/.flvx
cat > ~/.flvx/.env << 'EOF'
export FLVX_BASE_URL="https://panel.example.com"
export FLVX_USERNAME="admin"
export FLVX_PASSWORD="your-password"
EOF
chmod 600 ~/.flvx/.env
source ~/.flvx/.env
```
---
## 工具接入方法
### OpenCode
OpenCode 是命令行 AI 编程助手,支持通过 skills 扩展能力。
**安装 skill:**
```bash
npm install -g @flvx/skill-api
```
**使用:**
```bash
export FLVX_BASE_URL="https://panel.example.com"
export FLVX_USERNAME="admin"
export FLVX_PASSWORD="your-password"
opencode
```
**示例对话:**
```
你: 查看我的转发列表
你: 创建一个转发到 192.168.1.100:80 使用隧道 1
你: 检查节点状态
你: 查看流量使用情况
```
---
### OpenClaw
OpenClaw 同样支持 skills 机制。
**安装 skill:**
```bash
npm install -g @flvx/skill-api
# 或手动链接
mkdir -p ~/.openclaw/skills
ln -sf /path/to/flvx/skills/flvx-api ~/.openclaw/skills/flvx-api
```
**使用:**
```bash
openclaw
>>> 查看所有节点状态
>>> 给用户 alice 分配 50GB 流量
>>> 导出系统备份
```
---
### Claude Code
Claude Code 是 Anthropic 官方的命令行工具,支持通过 CLAUDE.md 扩展。
#### 方式 1: 项目级 CLAUDE.md
在项目根目录创建 `CLAUDE.md`:
```markdown
# FLVX API Skill
你可以通过 REST API 操作 FLVX 面板。
## 环境变量
- FLVX_BASE_URL: 面板地址
- FLVX_USERNAME: 用户名
- FLVX_PASSWORD: 密码
## 认证规则
- Authorization 头使用原始 JWT token,不加 "Bearer " 前缀
- 所有 API 使用 POST 方法
## 常用 API
### 登录获取 token
POST /api/v1/user/login
{"username": "...", "password": "..."}
### 查看转发列表
POST /api/v1/forward/list
Authorization: <token>
{}
### 创建转发
POST /api/v1/forward/create
{"name": "xxx", "tunnelId": 1, "remoteAddr": "1.2.3.4:80"}
### 查看节点
POST /api/v1/node/list
{}
```
**使用:**
```bash
cd /path/to/your/project
claude
```
#### 方式 2: 全局 CLAUDE.md
```bash
mkdir -p ~/.claude
cat > ~/.claude/CLAUDE.md << 'EOF'
# FLVX Panel Operations
使用 FLVX REST API 操作流量转发面板。
环境变量: FLVX_BASE_URL, FLVX_USERNAME, FLVX_PASSWORD
调用方式: curl -X POST "$FLVX_BASE_URL/api/v1/..." -H "Authorization: $TOKEN"
注意: Authorization 不要加 Bearer 前缀
EOF
```
#### 方式 3: 复制 SKILL.md
```bash
cat ~/.agents/skills/flvx-api/SKILL.md >> ~/.claude/CLAUDE.md
```
**示例对话:**
```
>>> 帮我查看 FLVX 面板上有哪些节点
>>> 创建一个名为 test 的转发,目标地址 10.0.0.1:80
>>> 查看我的流量使用情况
```
---
## API 覆盖
| 模块 | 操作 |
|------|------|
| 认证 | 登录、Token 管理 |
| 用户 | 增删改查、流量重置、密码 |
| 节点 | 增删改查、安装、升级、状态 |
| 隧道 | 增删改查、用户分配 |
| 转发 | 增删改查、暂停/恢复、诊断 |
| 分组 | 用户/隧道分组、权限 |
| 限速 | 增删改查 |
| 联邦 | 节点共享、远程节点 |
| 备份 | 导出/导入 |
## 安全提示
- ⚠️ 环境变量在进程列表中可见
- 使用 `~/.flvx/.env` 文件并设置 `chmod 600`
- 添加 `export HISTIGNORE="*FLVX_PASSWORD*"` 防止密码进入历史记录
- Token 仅在会话内存中缓存,不写入磁盘
## 发布
维护者可通过以下方式发布新版本:
```bash
# 方式 1: 推送 tag
git tag skill-v2.1.6
git push --tags
# 方式 2: GitHub Actions 手动触发
# 在 Actions 页面运行 publish-skill workflow
```
需要在 GitHub 仓库设置 `NPM_TOKEN` secret。
-40
View File
@@ -1,40 +0,0 @@
# 常见问题 (FAQ)
### Q1: 安装脚本提示 "Docker command not found"?
**A**: 请确保您的系统已安装 Docker 和 Docker Compose。
- Ubuntu/Debian 安装 Docker: `curl -fsSL https://get.docker.com | bash`
### Q2: 面板无法访问 (Connection Refused)?
**A**:
1. 检查防火墙是否放行了前端端口(默认 `6366`)。
2. 检查容器是否正常运行: `docker ps`。
3. 查看容器日志: `docker logs flux-panel-backend` 或 `docker logs vite-frontend`。
### Q3: 节点显示离线?
**A**:
1. 检查节点服务器与面板服务器之间的网络连通性。
2. 确认在节点端安装时输入的 **面板地址** 和 **密钥** 是否正确。
3. 检查节点端服务状态: `systemctl status flux_agent`。
4. 查看节点端日志: `journalctl -u flux_agent -f`。
### Q4: 只有 TCP 能通,UDP 不通?
**A**: 请检查服务器防火墙和安全组(AWS/阿里云/腾讯云等)是否同时放行了对应端口的 **TCP 和 UDP** 协议。
### Q5: IPv6 无法使用?
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
### Q6: 如何切换到 PostgreSQL?
**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres`,并让 `DATABASE_URL` 与 `POSTGRES_*` 保持一致,然后执行 `docker compose up -d` 重启服务即可。使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。详见 [PostgreSQL 数据库指南](./postgresql.md)。
### Q7: 从 SQLite 迁移到 PostgreSQL 后数据丢失?
**A**:
1. 确认迁移前已备份 SQLite 文件(`gost.db.bak`)。
2. 确认 `pgloader` 命令执行成功,检查其输出是否有报错。
3. 确认 `.env` 中 `DATABASE_URL` 的密码与 `POSTGRES_PASSWORD` 一致。
4. 详细迁移步骤参考 [PostgreSQL 数据库指南 - 从 SQLite 迁移](./postgresql.md)。
### Q8: PostgreSQL 容器启动失败?
**A**:
1. 检查 `POSTGRES_PASSWORD` 是否已设置(不能为空)。
2. 查看容器日志:`docker logs flux-panel-postgres`。
3. 如果是首次启动后修改了密码,需要删除旧的数据卷重新初始化:`docker volume rm postgres_data`。
-39
View File
@@ -1,39 +0,0 @@
# FLVX 官方文档
**FLVX** 是一个基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 开发的高性能流量转发管理系统。
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
## 核心特性
- **多协议支持**: 完美支持 TCP 和 UDP 协议转发。
- **灵活转发**: 支持 **端口转发** 与 **隧道转发** 两种模式。
- **流量控制**: 支持按 **隧道账号级别** 管理流量转发数量,用于用户/隧道配额控制。
- **限速管理**: 可针对 **指定用户的指定隧道进行限速** 设置。
- **计费策略**: 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型。
- **策略配置**: 提供灵活的转发策略配置,适用于多种网络场景。
## 快速开始
- [安装部署](./install.md)
- [使用指南](./usage.md)
- [PostgreSQL 数据库指南](./postgresql.md)
- [AI Skill 接入](./ai-skill.md) - 让大模型直接操作面板
- [常见问题](./faq.md)
## 免责声明
本项目仅供个人学习与研究使用,基于开源项目进行二次开发。
使用本项目所带来的任何风险均由使用者自行承担。本项目为开源的流量转发工具,仅限合法、合规用途。
**禁止将本项目用于任何违法或未经授权的行为,包括但不限于网络攻击、数据窃取、非法访问等。**
## 捐赠支持
如果您觉得本项目对您有帮助,欢迎请作者喝杯咖啡!
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| Polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
-135
View File
@@ -1,135 +0,0 @@
# 安装部署指南
本文档介绍如何部署 FLVX 面板端及节点端。
## 一、面板端部署
面板端负责管理用户、节点和转发规则。
### 1. 环境要求
- 操作系统:Linux (推荐 Debian 10+ / Ubuntu 20.04+)
- 必须安装 Docker 和 Docker Compose
### 2. 一键安装脚本
使用以下命令即可快速安装面板:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
**安装过程中会提示输入以下信息:**
- **前端端口**: 默认为 `6366`
- **后端端口**: 默认为 `6365`
脚本会自动检测系统是否支持 IPv6,并自动配置 Docker 的 IPv6 支持。
### 3. 访问面板
安装完成后,访问:
`http://<服务器IP>:<前端端口>` (默认: `http://<服务器IP>:6366`)
**默认管理员账号:**
- 用户名: `admin_user`
- 密码: `admin_user`
> ⚠️ **注意**: 首次登录后,请务必在“个人中心”或“设置”中修改默认密码!
### 4. 维护命令
再次运行 `./panel_install.sh` 脚本可以看到管理菜单:
1. 安装面板
2. 更新面板
3. 卸载面板
4. 迁移到 PostgreSQL
5. 退出
---
## 二、节点端部署
节点端运行在实际进行流量转发的服务器上,需要连接到面板端进行管理。
### 1. 获取接入密钥
1. 登录面板端。
2. 进入 **节点管理 (Node)** 页面。
3. 点击 **添加节点**。
4. 获取该节点的 **接入密钥 (Secret)**。
### 2. 一键安装脚本
在节点服务器上运行:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
**安装过程中会提示输入:**
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
- **密钥**: 刚才在面板中获取的节点密钥。
或者直接使用带参数的命令(适用于自动化部署):
```bash
# 替换 <面板地址> 和 <密钥>
./install.sh -a "http://1.2.3.4:6365" -s "your_node_secret"
```
### 3. 验证安装
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
---
## 三、Caddy 反向代理(可选)
如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。
### 1. 安装 Caddy
```bash
# Debian / Ubuntu
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。
### 2. 配置 Caddyfile
编辑 Caddy 配置文件:
```bash
sudo nano /etc/caddy/Caddyfile
```
#### 面板域名配置
将 `panel.example.com` 替换为你自己的域名:
```caddyfile
panel.example.com {
reverse_proxy localhost:6366
}
```
Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。
### 3. 重启 Caddy
```bash
sudo systemctl restart caddy
```
### 4. 注意事项
- 确保域名已正确解析到服务器 IP。
- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。
- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露:
```
FRONTEND_PORT=127.0.0.1:6366
```
-274
View File
@@ -1,274 +0,0 @@
# PostgreSQL 数据库指南
FLVX 默认使用 SQLite 作为数据库,同时也内置了对 PostgreSQL 的完整支持。本文档介绍如何使用 PostgreSQL 部署面板、从 SQLite 迁移以及日常维护。
## 一、SQLite 与 PostgreSQL 对比
| 特性 | SQLite | PostgreSQL |
|------|--------|------------|
| **部署复杂度** | 零配置,开箱即用 | 需要额外的数据库服务 |
| **并发性能** | 适合小规模单机使用 | 支持高并发读写 |
| **数据规模** | 适合中小规模数据 | 适合大规模数据 |
| **备份与恢复** | 直接复制文件 | 支持逻辑备份与物理备份 |
| **高可用** | 不支持 | 支持主从复制、流复制 |
**建议**:如果你只是个人使用或小团队使用,SQLite 完全够用。如果节点多,推荐使用 PostgreSQL。
---
## 二、环境变量说明
以下环境变量用于配置数据库连接,在 `.env` 文件或 Docker Compose `environment` 中设置。
### 后端服务 (backend) 使用
| 变量名 | 说明 | 默认值 | 示例 |
|--------|------|--------|------|
| `DB_TYPE` | 数据库类型,`sqlite` 或 `postgres` | `sqlite` | `postgres` |
| `DATABASE_URL` | PostgreSQL 连接字符串(仅 `DB_TYPE=postgres` 时必填) | 空 | `postgres://flux_panel:密码@postgres:5432/flux_panel?sslmode=disable` |
| `DB_PATH` | SQLite 数据库文件路径(仅 `DB_TYPE=sqlite` 时使用) | `/app/data/gost.db` | `/app/data/gost.db` |
### PostgreSQL 容器使用
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `POSTGRES_DB` | 数据库名称 | `flux_panel` |
| `POSTGRES_USER` | 数据库用户名 | `flux_panel` |
| `POSTGRES_PASSWORD` | 数据库密码 | `flux_panel_change_me` |
> ⚠️ **安全提示**:生产环境中请务必修改 `POSTGRES_PASSWORD` 为强密码,不要使用默认值!
---
## 三、全新部署(Docker Compose + PostgreSQL)
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认使用 SQLite,只需配置环境变量即可切换到 PostgreSQL。
### 1. 创建 `.env` 文件
在 `docker-compose` 同目录创建 `.env` 文件:
```bash
# 基础配置
JWT_SECRET=替换为你的密钥
BACKEND_PORT=6365
FRONTEND_PORT=6366
# PostgreSQL 配置
DB_TYPE=postgres
DATABASE_URL=postgres://flux_panel:替换为强密码@postgres:5432/flux_panel?sslmode=disable
POSTGRES_DB=flux_panel
POSTGRES_USER=flux_panel
POSTGRES_PASSWORD=替换为强密码
```
> ⚠️ `DATABASE_URL` 中的密码必须与 `POSTGRES_PASSWORD` 保持一致。
### 2. 启动服务
```bash
docker compose up -d
```
### 3. 验证
```bash
# 检查所有容器是否正常运行
docker ps
# 查看后端日志,确认连接 PostgreSQL 成功
docker logs flux-panel-backend
# 查看 PostgreSQL 容器状态
docker logs flux-panel-postgres
```
---
## 四、从 SQLite 迁移到 PostgreSQL
如果你已经在使用 SQLite 并且希望迁移到 PostgreSQL,请按照以下步骤操作。
### 快速方式:脚本菜单一键迁移(推荐)
如果你是通过安装脚本部署面板,可直接执行:
```bash
./panel_install.sh
# 选择 4. 迁移到 PostgreSQL
```
脚本会自动完成以下操作:
- 备份 SQLite 数据到当前目录(`gost.db.bak`)
- 启动并等待 PostgreSQL 健康检查通过
- 使用 `pgloader` 导入 SQLite 数据
- 自动写入 `.env` 的 `DB_TYPE=postgres` 与 `DATABASE_URL`
- 重启服务并等待后端健康检查
### 手动方式:按步骤迁移
### 1. 备份 SQLite 数据
```bash
# 停止所有服务
docker compose down
# 备份 SQLite 数据文件到当前目录
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
```
### 2. 配置 PostgreSQL 环境变量
在 `.env` 文件中添加 PostgreSQL 配置(参考上方"环境变量说明")。
### 3. 仅启动 PostgreSQL
```bash
docker compose up -d postgres
```
等待 PostgreSQL 完全就绪:
```bash
# 检查 PostgreSQL 健康状态
docker inspect --format='{{.State.Health.Status}}' flux-panel-postgres
# 输出 "healthy" 表示就绪
```
### 4. 使用 pgloader 迁移数据
```bash
source .env
docker run --rm \
--network gost-network \
-v sqlite_data:/sqlite \
dimitri/pgloader:latest \
pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
```
> 📌 建议直接从 `.env` 读取 `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB`,避免手填密码导致认证失败。
### 5. 启动全部服务
```bash
source .env
export DB_TYPE=postgres
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
docker compose up -d
```
### 6. 验证迁移
登录面板后,检查以下数据是否完整:
- 用户列表和权限
- 节点信息和状态
- 隧道配置
- 转发规则
- 流量统计数据
---
## 五、独立 PostgreSQL(非 Docker)
如果你不想使用 Docker Compose 中自带的 PostgreSQL 容器,也可以连接外部的 PostgreSQL 实例。
### 1. 准备 PostgreSQL
在目标 PostgreSQL 服务器上创建数据库和用户:
```sql
CREATE USER flux_panel WITH PASSWORD '你的强密码';
CREATE DATABASE flux_panel OWNER flux_panel;
```
### 2. 配置连接
修改 `.env` 文件,将 `DATABASE_URL` 指向外部 PostgreSQL:
```bash
DB_TYPE=postgres
DATABASE_URL=postgres://flux_panel:你的强密码@数据库地址:5432/flux_panel?sslmode=disable
```
> 📌 如果 PostgreSQL 在远程服务器且启用了 SSL,请将 `sslmode=disable` 改为 `sslmode=require` 或 `sslmode=verify-full`。
### 3. 停用内置 PostgreSQL 容器(可选)
如果使用外部 PostgreSQL,可以在启动时不启动内置的 postgres 服务:
```bash
docker compose up -d backend frontend
```
---
## 六、数据备份与恢复
### 逻辑备份(pg_dump)
```bash
# 备份(在 Docker 环境下)
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel > backup_$(date +%Y%m%d_%H%M%S).sql
# 恢复
docker exec -i flux-panel-postgres psql -U flux_panel flux_panel < backup_20260101_120000.sql
```
### 定时备份(cron)
创建备份脚本 `/opt/flvx/backup.sh`:
```bash
#!/bin/bash
BACKUP_DIR="/opt/flvx/backups"
mkdir -p "$BACKUP_DIR"
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel | gzip > "$BACKUP_DIR/flvx_$(date +%Y%m%d_%H%M%S).sql.gz"
# 清理 30 天前的备份
find "$BACKUP_DIR" -name "flvx_*.sql.gz" -mtime +30 -delete
```
添加 cron 任务(每天凌晨 3 点执行):
```bash
chmod +x /opt/flvx/backup.sh
echo "0 3 * * * /opt/flvx/backup.sh" | crontab -
```
---
## 七、常见问题
### Q: 切换到 PostgreSQL 后启动失败,提示连接被拒绝?
**A**:
1. 确认 PostgreSQL 容器已启动并处于 `healthy` 状态:`docker ps`。
2. 确认 `DATABASE_URL` 中的主机名、端口、用户名、密码正确。
3. 在 Docker Compose 环境下,主机名应为 `postgres`(服务名),而非 `localhost`。
### Q: pgloader 迁移时报错?
**A**:
1. 确认 PostgreSQL 容器已完全就绪(状态为 `healthy`)。
2. 确认 `--network gost-network` 参数正确,使 pgloader 容器与 PostgreSQL 在同一网络中。
3. 如果数据库已有表结构,pgloader 可能会报冲突。可以先清空目标数据库后重试。
### Q: 如何查看当前使用的数据库类型?
**A**: 查看后端容器的 `DB_TYPE` 环境变量:
```bash
docker exec flux-panel-backend printenv DB_TYPE
```
### Q: 可以同时使用 SQLite 和 PostgreSQL 吗?
**A**: 不可以。`DB_TYPE` 只能设置为 `sqlite` 或 `postgres` 之一。后端启动时根据此配置连接对应的数据库。
### Q: PostgreSQL 数据存储在哪里?
**A**: 在 Docker Compose 部署中,PostgreSQL 数据存储在名为 `postgres_data` 的 Docker Volume 中。可以通过以下命令查看:
```bash
docker volume inspect postgres_data
```
-35
View File
@@ -1,35 +0,0 @@
# 使用指南
## 1. 仪表盘 (Dashboard)
登录系统后首先看到的是仪表盘,这里展示了系统的整体运行状态,包括:
- 在线节点数量
- 用户总数
- 流量统计信息
## 2. 节点管理 (Node)
节点是实际承载流量转发的服务器。
- **添加节点**: 点击“添加”,获取密钥用于节点端安装。
- **管理**: 可以查看节点在线状态、版本信息,以及对节点进行编辑或删除。
## 3. 用户管理 (User)
管理员可以创建和管理普通用户。
- **创建用户**: 设置用户名、密码、流量配额等。
- **用户组**: 可以将用户分配到不同的组 (Group),便于统一管理权限或策略。
## 4. 转发管理 (Forward)
这是核心功能区,用于设置端口转发规则。
- **端口转发**: 将节点服务器的某个端口流量转发到目标地址。
- **协议**: 支持 TCP / UDP。
- **入口**: 选择入口节点和监听端口。
- **出口**: 设置目标 IP 和端口。
- **隧道转发**: 用于更复杂的网络穿透场景(具体配置视业务需求而定)。
## 5. 限制与策略 (Limit)
- **限速**: 可以对指定用户或指定隧道进行带宽限制,防止资源滥用。
- **计费模式**: 支持配置流量计算方式(单向或双向),适合运营场景。
## 6. 系统配置 (Config)
在此页面进行系统的全局设置。
## 7. 个人设置 (Profile)
- **修改密码**: 为了安全,建议定期修改管理员密码。
+46 -52
View File
@@ -1,68 +1,62 @@
services:
backend:
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
container_name: flux-panel-backend
mysql:
image: mysql:5.7
container_name: gost-mysql
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
SERVER_ADDR: :6365
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
TZ: Asia/Shanghai
volumes:
- mysql_data:/var/lib/mysql
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--max_connections=1000
--innodb_buffer_pool_size=256M
networks:
- gost-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 10s
retries: 10
backend:
image: bqlpfy/springboot-backend:1.4.3
container_name: springboot-backend
restart: unless-stopped
environment:
DB_HOST: mysql
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ports:
- "${BACKEND_PORT}:6365"
volumes:
- sqlite_data:/app/data
- backend_logs:/app/logs
depends_on:
mysql:
condition: service_healthy
networks:
- gost-network
stop_grace_period: 30s
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
postgres:
image: postgres:16-alpine
container_name: flux-panel-postgres
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gost-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
start_period: 90s
frontend:
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
image: bqlpfy/vite-frontend:1.4.3
container_name: vite-frontend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -73,11 +67,11 @@ services:
volumes:
sqlite_data:
name: sqlite_data
mysql_data:
name: mysql_data
driver: local
postgres_data:
name: postgres_data
backend_logs:
name: backend_logs
driver: local
@@ -87,4 +81,4 @@ networks:
driver: bridge
ipam:
config:
- subnet: 172.80.0.0/16
- subnet: 172.20.0.0/16
+47 -53
View File
@@ -1,68 +1,62 @@
services:
backend:
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
container_name: flux-panel-backend
mysql:
image: mysql:5.7
container_name: gost-mysql
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
SERVER_ADDR: :6365
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
TZ: Asia/Shanghai
volumes:
- mysql_data:/var/lib/mysql
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--max_connections=1000
--innodb_buffer_pool_size=256M
networks:
- gost-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 10s
retries: 10
backend:
image: bqlpfy/springboot-backend:1.4.3
container_name: springboot-backend
restart: unless-stopped
environment:
DB_HOST: mysql
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ports:
- "${BACKEND_PORT}:6365"
volumes:
- sqlite_data:/app/data
- backend_logs:/app/logs
depends_on:
mysql:
condition: service_healthy
networks:
- gost-network
stop_grace_period: 30s
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
postgres:
image: postgres:16-alpine
container_name: flux-panel-postgres
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gost-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
start_period: 90s
frontend:
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
image: bqlpfy/vite-frontend:1.4.3
container_name: vite-frontend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -73,11 +67,11 @@ services:
volumes:
sqlite_data:
name: sqlite_data
mysql_data:
name: mysql_data
driver: local
postgres_data:
name: postgres_data
backend_logs:
name: backend_logs
driver: local
@@ -88,5 +82,5 @@ networks:
enable_ipv6: true
ipam:
config:
- subnet: 172.80.0.0/16
- subnet: fd00:dead:beef::/48
- subnet: 172.20.0.0/16
- subnet: fd00:dead:beef::/48
@@ -1,132 +0,0 @@
# Floating Save Button Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a floating save button (FAB) to the config page that appears when configuration changes are detected.
**Architecture:** Inline FAB implementation using framer-motion AnimatePresence for enter/exit animations. Fixed-position circular button with slide-up animation, reusing existing hasChanges state and handleSave function.
**Tech Stack:** React, framer-motion (v11.18.2), shadcn-bridge/heroui Button, Tailwind CSS
---
## File Structure
| File | Action | Purpose |
|------|--------|---------|
| `vite-frontend/src/pages/config.tsx` | Modify | Add FAB imports and component at page bottom |
---
### Task 1: Add framer-motion Imports
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx:1-5`
- [ ] **Step 1: Add AnimatePresence and motion imports**
Add import statement after existing framer-motion imports (or at top if none exist).
Current imports at line 1-2:
```typescript
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
```
Add new import after line 2:
```typescript
import { AnimatePresence, motion } from "framer-motion";
```
- [ ] **Step 2: Commit import addition**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(config): add framer-motion imports for FAB animation"
```
---
### Task 2: Add FAB Component
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx:1220-1224` (end of component)
- [ ] **Step 1: Add FAB at end of component (before closing div)**
Locate the end of `ConfigPage` component (line ~1223, the closing `</div>` after all modals).
Insert FAB component before the closing `</div>`:
```tsx
{/* Floating Save Button (FAB) */}
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
```
- [ ] **Step 2: Run dev server to verify**
```bash
cd vite-frontend && npm run dev
```
Manual verification checklist:
- Open config page at http://localhost:3000/config
- Modify any config field
- Verify FAB appears with slide-up animation
- Click FAB to save
- Verify FAB disappears with slide-down animation after save
- Scroll page and verify FAB stays fixed in viewport corner
- Test on mobile viewport (resize browser or use dev tools)
- [ ] **Step 3: Commit FAB implementation**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(config): add floating save button (FAB) for issue #266"
```
---
## Verification Summary
| Requirement | Verification Method |
|-------------|---------------------|
| FAB hidden by default | Visual: no FAB on page load with no changes |
| FAB appears on change | Visual: modify field → FAB slides up |
| Fixed position | Visual: scroll page → FAB stays in corner |
| Slide-up animation | Visual: observe animation timing/bounce |
| Slide-down on save | Visual: click save → FAB slides down |
| Loading state | Visual: click save → spinner shown during save |
| Mobile compatibility | Visual: resize to mobile viewport → same behavior |
---
## Self-Review Checklist
- [x] Spec coverage: All requirements from design doc covered (imports + FAB component, animation params, button style, interaction behavior)
- [x] No placeholders: All code shown, no TBD/TODO
- [x] Type consistency: SaveIcon (line 45-59), handleSave (line 372-434), hasChanges (line 214), saving (line 213) all exist in config.tsx
@@ -1,520 +0,0 @@
# GitHub 加速地址自定义配置实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速,配置影响全部下载场景。
**Architecture:** 使用现有 `vite_config` 表存储配置,后端 Handler 读取配置替换硬编码,前端复用现有配置项渲染逻辑,安装脚本支持环境变量和交互式询问。
**Tech Stack:** Go 1.24, React/TypeScript, Shell/Bash
---
## 文件结构
| 文件 | 操作 | 说明 |
|------|------|------|
| `go-backend/internal/http/handler/upgrade.go` | 修改 | 移除硬编码,添加配置读取函数 |
| `go-backend/internal/http/handler/mutations.go` | 修改 | `nodeInstall` 函数使用动态配置 |
| `vite-frontend/src/pages/config.tsx` | 修改 | 添加两个新配置项 |
| `install.sh` | 修改 | 支持交互式询问和环境变量 |
| `panel_install.sh` | 修改 | 支持交互式询问和环境变量 |
| `test-install-scripts-proxy.sh` | 新增 | 覆盖代理交互与下载 URL 回归 |
---
## Task 1: 后端 - upgrade.go 修改
**Files:**
- Modify: `go-backend/internal/http/handler/upgrade.go`
- [x] **Step 1: 移除硬编码常量,添加配置读取函数**
在 `upgrade.go` 中,移除 `githubProxy` 常量,添加 `getGithubProxyConfig` 函数:
找到第 16-26 行:
```go
const (
githubRepo = "Sagit-chu/flvx"
githubProxy = "https://gcode.hostcentral.cc"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
```
替换为:
```go
const (
githubRepo = "Sagit-chu/flvx"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
defaultGithubProxyEnabled = true
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
)
```
然后在 `releaseChannelLabel` 函数后(约第 71 行之后)添加新函数:
```go
// getGithubProxyConfig 获取 GitHub 加速配置
// 返回: (是否开启加速, 加速地址)
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = defaultGithubProxyEnabled
proxyURL = defaultGithubProxyURL
if h == nil || h.repo == nil {
return
}
// 读取开启状态
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
// 读取加速地址
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
// 确保 URL 格式正确
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
// buildGithubDownloadURL 构建 GitHub 下载地址
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
```
- [x] **Step 2: 修改 nodeUpgrade 函数使用动态配置**
找到第 152-159 行:
```go
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
```
替换为:
```go
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
```
- [x] **Step 3: 修改 nodeBatchUpgrade 函数使用动态配置**
找到第 216-223 行:
```go
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
```
替换为:
```go
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
```
- [x] **Step 4: 验证编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功,无错误
- [x] **Step 5: 提交**
```bash
git add go-backend/internal/http/handler/upgrade.go
git commit -m "feat(backend): use configurable github proxy for node upgrades"
```
---
## Task 2: 后端 - mutations.go 修改
**Files:**
- Modify: `go-backend/internal/http/handler/mutations.go`
- [x] **Step 1: 修改 nodeInstall 函数使用动态配置**
找到第 456 行:
```go
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
```
替换为:
```go
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
} else {
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret)
}
```
- [x] **Step 2: 验证编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功,无错误
- [x] **Step 3: 提交**
```bash
git add go-backend/internal/http/handler/mutations.go
git commit -m "feat(backend): use configurable github proxy for node install command"
```
---
## Task 3: 前端 - config.tsx 添加配置项
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- [x] **Step 1: 在 CONFIG_ITEMS 数组中添加配置项**
找到第 158 行(`CONFIG_ITEMS` 数组的结束位置):
```go
{
key: "cloudflare_secret_key",
label: "Cloudflare Secret Key",
placeholder: "请输入 Cloudflare Secret Key",
description: "Cloudflare Turnstile 密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
},
];
```
在 `];` 之前添加:
```typescript
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
```
- [x] **Step 2: 在缓存键列表中添加新键**
找到第 179-190 行:
```typescript
const configKeys = [
"app_name",
"captcha_enabled",
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"monitor_tunnel_quality_enabled",
"ip",
"panel_domain",
"app_logo",
"app_favicon",
];
```
在 `"app_favicon",` 之后添加:
```typescript
"github_proxy_enabled",
"github_proxy_url",
```
- [x] **Step 3: 验证前端编译**
Run: `cd vite-frontend && npm run build`
Expected: 编译成功,无错误
- [x] **Step 4: 提交**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(frontend): add github proxy config settings"
```
---
## Task 4: 安装脚本 - install.sh 修改
**Files:**
- Modify: `install.sh`
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
找到第 28-32 行:
```bash
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
}
```
替换为:
```bash
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
# 询问加速配置(如果未由面板传入)
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
```
- [x] **Step 2: 修改 install_flux_agent 函数添加询问**
找到第 211-214 行:
```bash
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
get_config_params
```
替换为:
```bash
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
# 询问加速配置(如果未由面板传入)
ask_proxy_config
get_config_params
```
- [ ] **Step 3: 提交**
```bash
git add install.sh
git commit -m "feat(script): add configurable github proxy for install.sh"
```
---
## Task 5: 安装脚本 - panel_install.sh 修改
**Files:**
- Modify: `panel_install.sh`
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
找到第 16-20 行:
```bash
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
}
```
替换为:
```bash
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
# 询问加速配置(如果未由面板传入)
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
```
- [x] **Step 2: 修改 install_panel 函数添加询问**
找到第 375-378 行:
```bash
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
check_docker
get_config_params
```
替换为:
```bash
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
# 询问加速配置(如果未由面板传入)
ask_proxy_config
check_docker
get_config_params
```
- [ ] **Step 3: 提交**
```bash
git add panel_install.sh
git commit -m "feat(script): add configurable github proxy for panel_install.sh"
```
---
## Task 6: 最终验证和提交
- [x] **Step 1: 验证后端编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功
- [x] **Step 2: 验证前端编译**
Run: `cd vite-frontend && npm run build`
Expected: 编译成功
- [x] **Step 3: 验证脚本语法**
Run: `bash -n install.sh && bash -n panel_install.sh && bash test-install-scripts-proxy.sh`
Expected: 无语法错误,且脚本代理回归测试通过
- [ ] **Step 4: 推送所有提交**
```bash
git push
```
---
## 验收标准
1. 面板设置页面显示 GitHub 加速配置项
2. 开关关闭后,下载地址直连 GitHub
3. 自定义加速地址后,节点更新和安装命令使用自定义地址
4. 安装脚本支持交互式询问加速配置
5. 面板生成的安装命令包含加速配置环境变量
@@ -1,220 +0,0 @@
# Commercial White-Label Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow users with a valid license key to activate commercial white-label features, enabling them to remove FLVX branding and use their own app name, logos, and footer.
**Architecture:** Backend API handles license validation and stores state (`is_commercial`). Both frontend and backend check this state to conditionally render or allow modifications to brand config.
**Tech Stack:** Go (Backend API), React + Vite (Frontend UI).
---
### Task 1: Backend License Activation Endpoint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add license request struct**
Add the `licenseActivateRequest` struct in `handler.go`.
```go
type licenseActivateRequest struct {
LicenseKey string `json:"license_key"`
}
```
- [ ] **Step 2: Add `licenseActivate` handler method**
Add the method to validate the key in `handler.go`.
```go
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req licenseActivateRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
key := strings.TrimSpace(req.LicenseKey)
if !strings.HasPrefix(key, "FLVX-") {
response.WriteJSON(w, response.ErrDefault("无效的商业授权码"))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
- [ ] **Step 3: Register the route**
In `handler.go` inside `Register(mux *http.ServeMux)`, add the route.
```go
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
```
- [ ] **Step 4: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add license activation endpoint"
```
### Task 2: Backend Config Update Validation
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add permission check in `updateConfigs`**
In `updateConfigs`, fetch `isCommercial := h.repo.GetConfig("is_commercial")`. Inside the loop, check if the user is trying to update protected keys.
```go
isCommercial, _ := h.repo.GetConfig("is_commercial")
protectedKeys := map[string]bool{
"app_name": true,
"app_logo": true,
"app_favicon": true,
"hide_footer_brand": true,
}
```
Inside `for k, v := range payload`:
```go
if protectedKeys[key] && isCommercial.Value != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
```
- [ ] **Step 2: Add permission check in `updateSingleConfig`**
In `updateSingleConfig`, do the same check before calling `normalizeAndValidateConfigValue`.
```go
isCommercial, _ := h.repo.GetConfig("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial.Value != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
```
- [ ] **Step 3: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add authorization check for commercial config keys"
```
### Task 3: Frontend API & Site Config Update
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/config/site.ts`
- [ ] **Step 1: Add `activateLicense` API**
In `vite-frontend/src/api/index.ts`:
```typescript
export const activateLicense = (licenseKey: string) =>
Network.post("/license/activate", { license_key: licenseKey });
```
- [ ] **Step 2: Update `siteConfig` defaults**
In `vite-frontend/src/config/site.ts`, inside `getInitialConfig()`, add properties.
```typescript
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: configCache.get("is_commercial") === "true",
hide_footer_brand: configCache.get("hide_footer_brand") === "true",
```
- [ ] **Step 3: Update `updateSiteConfig`**
In `updateSiteConfig` inside `site.ts`, extract and update `is_commercial` and `hide_footer_brand`.
```typescript
const isCommercial = resolvedConfigMap.is_commercial === "true";
const hideFooterBrand = resolvedConfigMap.hide_footer_brand === "true";
siteConfig.is_commercial = isCommercial;
siteConfig.hide_footer_brand = hideFooterBrand;
```
- [ ] **Step 4: Commit**
```bash
git add vite-frontend/src/api/index.ts vite-frontend/src/config/site.ts
git commit -m "feat: add frontend api and update site config state for license"
```
### Task 4: Frontend Footer Component Update
**Files:**
- Modify: `vite-frontend/src/components/version-footer.tsx`
- [ ] **Step 1: Conditionally hide "Powered by FLVX"**
In the render block, wrap the `Powered by FLVX` text.
```tsx
{siteConfig.hide_footer_brand !== true && (
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
)}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/components/version-footer.tsx
git commit -m "feat: conditionally hide flvx footer brand"
```
### Task 5: Frontend Settings Page UI Update
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- [ ] **Step 1: Add config keys to initialization**
In `getInitialConfigs`, add `"is_commercial"` and `"hide_footer_brand"` to `configKeys`.
- [ ] **Step 2: Add `hide_footer_brand` switch field**
Add it to the `CONFIG_ITEMS` array.
```typescript
{
key: "hide_footer_brand",
label: "隐藏页面底部 FLVX 版权信息",
description: "需商业版授权才能生效",
type: "switch",
},
```
- [ ] **Step 3: Add license activation UI**
Above the System Config Card (near `value="configs"`), add a new `Card` for "商业版授权". You will need a local state `licenseKey` and an `handleActivateLicense` function that calls `activateLicense(licenseKey)` and refetches configs on success.
- [ ] **Step 4: Disable brand settings when not commercial**
In `renderConfigItem`, compute `isDisabled` and pass it to the `<Input>`, `<Switch>`, and `BrandUploading` UI. Update the logic to disable modifications and add a lock icon or a tooltip explaining that a commercial license is required.
```typescript
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
```
- [ ] **Step 5: Commit**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat: ui settings for commercial white-label and license activation"
```
@@ -1,347 +0,0 @@
# Commercial White-Label (Keygen) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Keygen.sh license activation and periodic validation to manage commercial white-label features, replacing the temporary mock logic.
**Architecture:** The backend generates a machine fingerprint, validates the license via the Keygen.sh API, and creates a machine associated with the license. A periodic job verifies the license status to support remote revocation.
**Tech Stack:** Go (Backend API), Keygen.sh API.
---
### Task 1: Generate and Store Machine Fingerprint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add `getOrCreateMachineFingerprint` helper function**
Add a helper function in `handler.go` (or a dedicated license file) to get or generate the machine fingerprint. Use `github.com/google/uuid`.
```go
import "github.com/google/uuid"
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
return "", err
}
return newFp, nil
}
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add machine fingerprint generation"
```
### Task 2: Create Keygen Client Package
**Files:**
- Create: `go-backend/internal/license/keygen.go`
- [ ] **Step 1: Create Keygen client structs and interface**
Create the file and define the request/response structs for Keygen's `/licenses/actions/validate-key` and `/machines` endpoints. Also define an interface for the client.
```go
package license
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type KeygenClient struct {
AccountID string
Token string
HTTPClient *http.Client
}
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
Code string `json:"code"`
} `json:"meta"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
type ActivateMachineRequest struct {
Data struct {
Type string `json:"type"`
Attributes struct {
Fingerprint string `json:"fingerprint"`
} `json:"attributes"`
Relationships struct {
License struct {
Data struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"data"`
} `json:"license"`
} `json:"relationships"`
} `json:"data"`
}
```
- [ ] **Step 2: Implement `ValidateKey`**
Add the `ValidateKey` method.
```go
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
reqBody := map[string]interface{}{
"meta": map[string]string{
"key": key,
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
```
- [ ] **Step 3: Implement `ActivateMachine`**
Add the `ActivateMachine` method.
```go
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
reqBody.Data.Attributes.Fingerprint = fingerprint
reqBody.Data.Relationships.License.Data.Type = "licenses"
reqBody.Data.Relationships.License.Data.ID = licenseID
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusConflict { // 409 usually means fingerprint already exists
return nil // Machine might already be registered
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
```
- [ ] **Step 4: Commit**
```bash
git add go-backend/internal/license/keygen.go
git commit -m "feat: add keygen.sh api client"
```
### Task 3: Integrate Keygen into License Activation Endpoint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Update `licenseActivate` logic**
Modify `licenseActivate` to use the Keygen client instead of the mock logic. Note: For this implementation, we will use an environment variable `KEYGEN_ACCOUNT_ID`. We can use `os.Getenv` directly for simplicity, or hardcode a fallback if not present.
```go
import (
"go-backend/internal/license"
"os"
)
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
// ... (keep request parsing)
key := strings.TrimSpace(req.LicenseKey)
if key == "" {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
if accountID == "" {
// Fallback for mock/development if no keygen account configured
if strings.HasPrefix(key, "FLVX-") {
now := time.Now().UnixMilli()
h.repo.UpsertConfig("license_key", key, now)
h.repo.UpsertConfig("is_commercial", "true", now)
response.WriteJSON(w, response.OKEmpty())
return
}
response.WriteJSON(w, response.ErrDefault("系统未配置 Keygen 账号 ID"))
return
}
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "") // Token may be optional for validate-key depending on policy, or can be passed if needed
valResp, err := client.ValidateKey(key)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
// Try to activate machine
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: integrate keygen into license activation endpoint"
```
### Task 4: Add Periodic License Validation Job
**Files:**
- Modify: `go-backend/internal/http/handler/jobs.go`
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add `validateLicenseJob` function in `jobs.go`**
Create a new function that performs the background validation.
```go
import "os"
func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
if accountID == "" {
return // Skip if not configured
}
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKey(key)
if err != nil {
// Network error or timeout. We implement a grace period by NOT revoking immediately here.
// In a production system, you might count consecutive failures.
// For now, we skip revocation on network errors.
return
}
if !valResp.Meta.Valid {
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
// We could optionally clear brand configs here, or just let them be disabled in UI
}
}
```
- [ ] **Step 2: Register the job in `RunJobs`**
In `handler.go` or `jobs.go`, wherever the periodic cron jobs are registered (usually `go h.runJobs()`), ensure `validateLicenseJob` is called periodically (e.g., every 12 hours). Look for `h.startCronJobs()` or similar in `handler.go`.
If a central `RunJobs` loop exists in `jobs.go` (like a `for` loop with a `time.Ticker`), add it there. If not, create a simple goroutine in `Register` or `NewHandler`.
*Assuming there's a `startJobs` or `Init` block in `handler.go`:*
```go
// Inside handler initialization or Register:
go func() {
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.validateLicenseJob()
}
}
}()
```
- [ ] **Step 3: Commit**
```bash
git add go-backend/internal/http/handler/jobs.go go-backend/internal/http/handler/handler.go
git commit -m "feat: add periodic license validation job"
```
@@ -1,262 +0,0 @@
# Announcement Popup Notification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a popup modal for announcements that automatically shows to users when a new or updated announcement is published.
**Architecture:** We will modify the Go backend to return `update_time` along with the announcement data. In the Vite frontend, we will store the user's `flvx_announcement_seen_time` in `localStorage`. If the fetched `update_time` is greater than the stored timestamp, we trigger a NextUI Modal displaying the announcement content.
**Tech Stack:** Go, Vite, React, TailwindCSS, NextUI.
---
### Task 1: Update API Response in Go Backend
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Write the minimal implementation**
Modify the `getAnnouncement` function in `go-backend/internal/http/handler/handler.go`.
Find the response map inside `getAnnouncement` and add the `update_time` key:
```go
if ann == nil {
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": "",
"enabled": 0,
"update_time": 0,
}))
return
}
updateTime := ann.CreatedTime
if ann.UpdatedTime.Valid {
updateTime = ann.UpdatedTime.Int64
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": ann.Content,
"enabled": ann.Enabled,
"update_time": updateTime,
}))
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat(api): include update_time in announcement response"
```
---
### Task 2: Update Frontend API Interface
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- [ ] **Step 1: Write the minimal implementation**
Modify the `AnnouncementData` interface in `vite-frontend/src/api/index.ts` to include `update_time`.
```typescript
export interface AnnouncementData {
content: string;
enabled: number;
update_time?: number;
}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/api/index.ts
git commit -m "feat(ui): add update_time to AnnouncementData interface"
```
---
### Task 3: Create AnnouncementModal Component
**Files:**
- Create: `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx`
- [ ] **Step 1: Write the minimal implementation**
Create `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx` with the following content:
```tsx
import type { AnnouncementData } from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AnnouncementModalProps {
announcement: AnnouncementData;
isOpen: boolean;
onClose: () => void;
onDontShowAgain: () => void;
}
export const AnnouncementModal = ({
announcement,
isOpen,
onClose,
onDontShowAgain,
}: AnnouncementModalProps) => {
return (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody>
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{announcement.content}
</ReactMarkdown>
</div>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onDontShowAgain}>
不再提示
</Button>
<Button color="primary" onPress={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/pages/dashboard/components/announcement-modal.tsx
git commit -m "feat(ui): create AnnouncementModal component"
```
---
### Task 4: Integrate Modal State in Dashboard Custom Hook
**Files:**
- Modify: `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
- [ ] **Step 1: Update the hook return type interface**
At the top of `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` where `DashboardData` is or similar, add the new properties (if it uses an explicit return type). If it's inferred, skip this. Wait, let's check the code:
```typescript
isAnnouncementModalOpen: boolean;
setIsAnnouncementModalOpen: (isOpen: boolean) => void;
dismissAnnouncementModal: () => void;
```
Ensure they are added to the returned object at the bottom of the `useDashboardData` hook.
Find the `const loadAnnouncement` function.
- [ ] **Step 2: Write the minimal implementation**
First, add state at the top of the hook:
```typescript
const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false);
```
Then, modify the `loadAnnouncement` logic inside `useDashboardData`:
```typescript
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
try {
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
const updateTime = res.data.update_time || 0;
if (updateTime > storedTime) {
setIsAnnouncementModalOpen(true);
}
} catch (err) {
console.warn("Failed to read localStorage for announcement state", err);
setIsAnnouncementModalOpen(true);
}
} else {
setAnnouncement(null);
}
```
Add the dismiss handler inside the hook:
```typescript
const dismissAnnouncementModal = useCallback(() => {
setIsAnnouncementModalOpen(false);
if (announcement && announcement.update_time) {
try {
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
} catch (err) {
console.warn("Failed to set localStorage for announcement state", err);
}
}
}, [announcement]);
```
Ensure these are included in the return object of the hook:
```typescript
isAnnouncementModalOpen,
setIsAnnouncementModalOpen,
dismissAnnouncementModal,
```
- [ ] **Step 3: Commit**
```bash
git add vite-frontend/src/pages/dashboard/use-dashboard-data.ts
git commit -m "feat(ui): manage announcement modal state in dashboard hook"
```
---
### Task 5: Add Modal to Dashboard Layout
**Files:**
- Modify: `vite-frontend/src/pages/dashboard.tsx`
- [ ] **Step 1: Write the minimal implementation**
Import the modal component at the top:
```tsx
import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
```
Add the new properties to the destructured `useDashboardData` object:
```tsx
isAnnouncementModalOpen,
setIsAnnouncementModalOpen,
dismissAnnouncementModal,
```
Add the modal instance near the end of the dashboard rendering (just below `{announcement && <AnnouncementBanner ... />}` or inside the main `<div>`):
```tsx
{announcement && (
<AnnouncementModal
announcement={announcement}
isOpen={isAnnouncementModalOpen}
onClose={() => setIsAnnouncementModalOpen(false)}
onDontShowAgain={dismissAnnouncementModal}
/>
)}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/pages/dashboard.tsx
git commit -m "feat(ui): add announcement modal to dashboard layout"
```
@@ -1,140 +0,0 @@
# Flvx iOS 26 Liquid Glass UI Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Transform the Flvx frontend interface entirely into an "Apple iOS 26 Liquid Glass" visual style by utilizing high-radius squircles, heavy background blurs, mesh gradients, and highly semantic translucent containers.
**Architecture:** We will approach this from the ground up: first defining the global TailwindCSS design tokens and the base mesh-gradient layout, then systematically replacing the structural styling inside each React page component (`vite-frontend/src/pages/*.tsx`).
**Tech Stack:** React DOM, TailwindCSS (v4), shadcn-bridge (HeroUI), Vite
---
### Task 1: Setup Global CSS Variables and App Shell
**Files:**
- Modify: `vite-frontend/src/styles/globals.css` (or `index.css`)
- Modify: `vite-frontend/tailwind.config.js`
- Modify: `vite-frontend/src/App.tsx` (or `main.tsx` / `layouts` depending on structural entry point)
- [ ] **Step 1: Inject Liquid Glass theme variables**
Open the main CSS file and add variables for the new blur radius, box-shadows, and background gradients.
```css
:root {
--glass-bg: rgba(255, 255, 255, 0.6);
--glass-border: rgba(255, 255, 255, 0.8);
--glass-card: rgba(255, 255, 255, 0.7);
--glass-overlay: rgba(0, 0, 0, 0.3);
}
.dark {
--glass-bg: rgba(30, 30, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.15);
--glass-card: rgba(40, 40, 40, 0.6);
}
.bg-mesh-gradient {
background: radial-gradient(at 0% 0%, #ff9a9e 0%, transparent 50%),
radial-gradient(at 100% 0%, #fecfef 0%, transparent 50%),
radial-gradient(at 100% 100%, #c2e9fb 0%, transparent 50%),
radial-gradient(at 0% 100%, #a1c4fd 0%, transparent 50%);
background-color: #f2f2f7;
}
```
- [ ] **Step 2: Update App Layout**
Modify the root app container to use `.bg-mesh-gradient` and ensure the main container occupies `min-h-screen`.
### Task 2: Refactor Global Components (Card & Modal)
**Files:**
- Modify: `vite-frontend/src/shadcn-bridge/heroui/card.tsx`
- Modify: `vite-frontend/src/shadcn-bridge/heroui/modal.tsx`
- [ ] **Step 1: Liquid Card Base**
Update the default className string for `Card` to incorporate: `backdrop-blur-3xl bg-white/60 dark:bg-zinc-900/60 border border-white/80 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]`.
- [ ] **Step 2: Modal Overlay Base**
Update the default overlay className for `Modal` to use `bg-black/30 backdrop-blur-sm`, and its content panel to use the same `glass_card` classes as the Card component but with `rounded-3xl`.
### Task 3: Redesign Dashboard Page
**Files:**
- Modify: `vite-frontend/src/pages/dashboard.tsx`
- [ ] **Step 1: Replace hardcoded borders/bg with glass semantics**
Find hardcoded `bg-white`, `border-gray-200`, `shadow-md` inside `DashboardPage` and `MetricCard`, replace with `bg-white/60 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] border-white/80 rounded-2xl`.
- [ ] **Step 2: Adjust spacing**
Ensure all metric cards have uniform `h-48` equivalent height and are strictly `rounded-2xl` with `p-6` padding.
- [ ] **Step 3: Update Flow Chart Card**
Replace standard grid backgrounds in the flow chart with transparent spacing and vibrant `bg-blue-500` squircle bars without harsh borders.
### Task 4: Redesign Node Management Page
**Files:**
- Modify: `vite-frontend/src/pages/node.tsx`
- [ ] **Step 1: Replace standard List/Table view with Grid Cards**
Update the node rendering map to output `glass_card` containers (`rounded-2xl`, blur, padding `p-6`).
- [ ] **Step 2: Apply semantic status highlights**
Refactor the Online/Offline badges into pill-shapes (`rounded-full`) using the defined semantic colors (e.g. `bg-green-500/20 text-green-600` with a 6px inner dot `bg-green-500`).
- [ ] **Step 3: Embed Micro-charts**
For CPU/RAM data inside the node card, switch standard progress bars to ultra-thin (height 4px) continuous lines utilizing standard brand colors.
### Task 5: Redesign Tunnels & Rules Configuration
**Files:**
- Modify: `vite-frontend/src/pages/tunnel.tsx`
- Modify: `vite-frontend/src/pages/forward.tsx`
- [ ] **Step 1: Update Tunnel lists into nested Glass Panels**
Encapsulate each tunnel configuration into a wide `glass_card`.
- [ ] **Step 2: Create Visual Rule Tags**
For the Forwarding rules, wrap the target IP/Port logic into visual badges: `bg-green-500/20` for Entry and `bg-blue-500/20` for Target.
- [ ] **Step 3: Refactor the "Add Rule" Floating action**
Ensure the plus button follows the squircle format (`rounded-full`) with a prominent diffused shadow (`shadow-[0_4px_12px_rgba(0,122,255,0.3)]`).
### Task 6: Redesign Monitor Page
**Files:**
- Modify: `vite-frontend/src/pages/monitor.tsx`
- [ ] **Step 1: Style the Top Hero Metrics**
Replace flat stat boxes with high-contrast, large typography inside `glass_card` backgrounds.
- [ ] **Step 2: Refactor Latency Indicators**
Format the connection list rows as `bg-white/50 dark:bg-black/30` strips with pill-shaped status tags (`Healthy`, `Warning`, `Offline`) mapping exactly to the green/orange/red semantics from the design spec.
### Task 7: Redesign Group & Sharing Pages
**Files:**
- Modify: `vite-frontend/src/pages/group.tsx`
- Modify: `vite-frontend/src/pages/panel-sharing.tsx`
- [ ] **Step 1: Update Tab Switchers**
Refactor the internal navigation tabs (e.g., "Tunnel Groups" vs "User Groups") into an encapsulated `p-1 rounded-xl bg-white/40 backdrop-blur-lg` container with animated active states (`shadow-sm bg-white`).
- [ ] **Step 2: Style Share Cards**
Transform flat panel sharing list items into rich `glass_card` entities. Highlight expiration dates with the accent text color.
### Task 8: Redesign Settings, Config, and User Management
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- Modify: `vite-frontend/src/pages/settings.tsx`
- Modify: `vite-frontend/src/pages/user.tsx`
- Modify: `vite-frontend/src/pages/limit.tsx`
- [ ] **Step 1: Flatten Forms**
Convert traditional input groups into `rounded-xl bg-white/50 border border-white/60` containers. Remove outer boxing for standard `label + input` pairs.
- [ ] **Step 2: iOS Toggle Switches**
Ensure that any `<Switch>` or `<Checkbox>` components use the new Accent brand color (`#007aff`) with full `rounded-full` geometry.
- [ ] **Step 3: Refactor User Badges**
In `user.tsx`, replace text-based role columns with circular Avatar badges (e.g., `w-10 h-10 rounded-full bg-blue-500 text-white` with the first two letters of the username).
### Task 9: Profile & Password Modal Restyling
**Files:**
- Modify: `vite-frontend/src/pages/profile.tsx`
- Modify: `vite-frontend/src/pages/change-password.tsx`
- [ ] **Step 1: Apply Profile Card Structure**
Create a split view on desktop using flex: Left side (Avatar + User Info + Admin Shortcuts), Right side (Password Form).
- [ ] **Step 2: Restyle Auth Inputs**
Ensure all password inputs use `bg-white/50 backdrop-blur-md border border-white/60` and the update button has heavy shadow-glow.
@@ -1,482 +0,0 @@
# 最大连接数限制实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`[x]`) syntax for tracking.
**Goal:** 在 FLVX 中实现基于用户的全局最大连接数限制和基于单条规则的独立最大连接数限制功能。前端输入框为 0 或空时表示不限制。
**Architecture:** 采用“覆盖逻辑”(方案二)。
1. 数据库层面:在 `user` 和 `forward` 表中各增加一个整型字段 `max_conn`,默认值为 0(表示不限制)。
2. 后端接口层面:提供 API 更新该字段,在组装下发给 GOST 的配置时,判断规则的 `max_conn` 是否大于 0:
- 如果规则 `max_conn > 0`,则为此规则动态生成一个唯一的连接限制器配置,并在下发服务的 `climiter` 字段中引用该限制器。
- 如果规则 `max_conn == 0`,则检查该规则所属用户的 `max_conn`。
- 如果用户 `max_conn > 0`,则引用以用户维度的连接限制器配置(如 `user_conn_limit_<user_id>`)。
- 否则不下发 `climiter`。
3. 后端服务控制平面:需要在下发服务前,将需要的连接限制器(Rule 或 User 维度)推送到节点上。
- **重要发现:** 当前 `go-gost` 的 WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`) 仅支持 `TrafficLimiter` 的动态增删(如 `AddLimiters` 等),**不支持** `ConnLimiter`(即 `CLimiters`)。
- **计划修改:** 我们需要先在 `go-gost` 侧(`go-gost/x/socket`)添加针对 `CLimiters` 的 WebSocket 指令(`AddCLimiters`, `UpdateCLimiters`, `DeleteCLimiters`)以及对应的处理函数(参考 `AddLimiters` 等的实现,调用现有的针对 `ConnLimiterRegistry` 的相关接口和配置存储逻辑,具体需要实现类似 `createLimiter` 到 `createConnLimiter` 的逻辑)。
- 完成底层修改后,`go-backend` 再通过这些新增加的 WebSocket 指令,在 `ensureLimiterOnNode` 时下发最大连接数限制规则。
4. 前端层面:在用户管理和规则管理页面增加输入框组件。
**Tech Stack:** Go, GORM, SQLite/PostgreSQL, React, Vite, TypeScript, TailwindCSS.
---
### Task 1: 扩展 go-gost WebSocket 接口以支持 CLimiters
**Files:**
- Modify: `go-gost/x/socket/limiter.go`
- Modify: `go-gost/x/socket/websocket_reporter.go`
[x] **Step 1: 实现 `createConnLimiter` 等功能**
在 `go-gost/x/socket/limiter.go` 中参考现有 `createLimiter` 添加对 `CLimiters` 的支持:
```go
func createConnLimiter(req createLimiterRequest) error {
name := strings.TrimSpace(req.Data.Name)
if name == "" {
return errors.New("limiter name is required")
}
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
return errors.New("conn limiter " + name + " already exists")
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func updateConnLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
for i := range c.CLimiters {
if c.CLimiters[i].Name == name {
c.CLimiters[i] = &req.Data
return nil
}
}
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func deleteConnLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
if c := config.Global(); c != nil {
limiteres := c.CLimiters
c.CLimiters = nil
for _, s := range limiteres {
if s.Name == name {
continue
}
c.CLimiters = append(c.CLimiters, s)
}
}
return nil
}
```
[x] **Step 2: 在 `WebSocketReporter` 注册命令**
在 `go-gost/x/socket/websocket_reporter.go` 的 `ProcessCommand` 中添加 case:
```go
case "AddCLimiters":
err = w.handleAddCLimiter(cmd.Data)
response.Type = "AddCLimitersResponse"
needSaveConfig = true
case "UpdateCLimiters":
err = w.handleUpdateCLimiter(cmd.Data)
response.Type = "UpdateCLimitersResponse"
needSaveConfig = true
case "DeleteCLimiters":
err = w.handleDeleteCLimiter(cmd.Data)
response.Type = "DeleteCLimitersResponse"
needSaveConfig = true
```
[x] **Step 3: 实现 Handler 方法**
在 `go-gost/x/socket/websocket_reporter.go` 中添加:
```go
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析限流器配置失败: %v", err)
}
req := createLimiterRequest{Data: limiterConfig}
return createConnLimiter(req)
}
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var updateReq struct {
Limiter string `json:"limiter"`
Data config.LimiterConfig `json:"data"`
}
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析更新请求失败: %v", err)
}
updateReq.Limiter = limiterConfig.Name
updateReq.Data = limiterConfig
}
req := updateLimiterRequest{
Limiter: updateReq.Limiter,
Data: updateReq.Data,
}
return updateConnLimiter(req)
}
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var deleteReq deleteLimiterRequest
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
var limiterName string
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
return fmt.Errorf("解析删除请求失败: %v", err)
}
deleteReq.Limiter = limiterName
}
return deleteConnLimiter(deleteReq)
}
```
[x] **Step 4: Commit**
```bash
cd go-gost
git add x/socket/limiter.go x/socket/websocket_reporter.go
git commit -m "feat: add CLimiters support for websocket reporter"
cd ..
```
---
### Task 2: 数据库迁移与模型更新
**Files:**
- Modify: `go-backend/internal/store/model/model.go`
- Modify: `go-backend/internal/store/repo/repository.go`
[x] **Step 1: 更新数据库模型**
在 `go-backend/internal/store/model/model.go` 的 `User` 和 `Forward` 结构体中添加 `MaxConn` 字段。
```go
// 在 User 结构体中
type User struct {
// ...
MaxConn int `gorm:"column:max_conn;not null;default:0"`
// ...
}
// 在 Forward 结构体中
type Forward struct {
// ...
MaxConn int `gorm:"column:max_conn;not null;default:0"`
// ...
}
```
[x] **Step 2: 编写数据库迁移**
在 `go-backend/internal/store/repo/repository.go` 的 `AutoMigrate` 逻辑前(如果有自定义迁移)或利用 gorm 自动迁移机制,由于这是 autoMigrate,添加字段只要 `db.AutoMigrate(&model.User{}, &model.Forward{})` 被调用就能自动加上。确认已执行迁移。由于 `FLVX` 通常会自动执行迁移,只需修改模型即可。我们需要处理默认值,由于使用了 `default:0`,GORM 会处理新增字段的默认值,但为了安全起见,如果在旧环境中,可能直接 alter table。
```go
// 无需手动编写 SQL,依赖现有的 gorm AutoMigrate 即可。
```
[x] **Step 3: 运行并验证迁移通过**
Run: `make build` (在 go-backend 中),或者运行一个相关的存储单元测试。
[x] **Step 4: Commit**
```bash
cd go-backend
git add internal/store/model/model.go
git commit -m "feat: add max_conn field to user and forward models"
cd ..
```
---
### Task 3: 后端控制平面 - 连接数限制器的组装与下发
**Files:**
- Modify: `go-backend/internal/http/handler/control_plane.go`
- Modify: `go-backend/internal/store/repo/repository_control.go`
[x] **Step 1: 更新存储层以获取 User 的 MaxConn**
在 `go-backend/internal/store/repo/repository_control.go` 中:
需要一个方法获取 User,或者如果已经有,确保可以拿到 `MaxConn`。
[x] **Step 2: 编写下发 CLimiter 到节点的辅助函数**
在 `go-backend/internal/http/handler/control_plane.go`,参考 `ensureLimiterOnNode` 和 `upsertLimiterOnNode`:
```go
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
limitStr := fmt.Sprintf("$ %d", maxConn)
payload := map[string]interface{}{
"name": limiterName,
"limits": []string{limitStr},
}
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
if !isAlreadyExistsMessage(err.Error()) {
return fmt.Errorf("连接限制器下发失败: %w", err)
}
updatePayload := map[string]interface{}{
"limiter": limiterName,
"data": payload,
}
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
}
}
return nil
}
```
[x] **Step 3: 更新组装配置逻辑以绑定 `climiter`**
在 `control_plane.go` 的 `syncForwardServicesWithWarnings` 及其辅助函数 `buildForwardServiceConfigs` 附近:
修改 `buildForwardServiceConfigs` 的签名,传入 `maxConn int` 和对应的 `cLimiterName string`。
```go
func buildForwardServiceConfigs(baseName string, forward *model.Forward, tunnel *model.Tunnel, node *model.Node, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {
// ... 现有逻辑
// 在服务配置生成的部分增加:
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
// ...
}
```
[x] **Step 4: 在转发服务同步主流程中决定并下发 `climiter`**
在 `syncForwardServicesWithWarnings` (可能在多个重载/处理入口处,如 `ensureForwardServices`),查出转发所属 user 的 `MaxConn`,以及转发本身的 `MaxConn`。
```go
// 获取 User
user, err := h.repo.GetUser(forward.UserID)
if err != nil {
return nil, err
}
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
if cLimiterName != "" {
for _, fp := range ports {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
}
// 传递给 buildForwardServiceConfigs
// ...
```
*(注意:需要确保更新涉及 `buildForwardServiceConfigs` 的所有调用点)*
[x] **Step 5: Commit**
```bash
cd go-backend
git add internal/http/handler/control_plane.go internal/store/repo/repository_control.go
git commit -m "feat: implement max conn limiter dispatching"
cd ..
```
---
### Task 4: 后端接口 - 用户和规则的 CRUD 支持
**Files:**
- Modify: `go-backend/internal/http/handler/admin_user.go`
- Modify: `go-backend/internal/http/handler/forward.go`
[x] **Step 1: 用户接口更新**
在 `go-backend/internal/http/handler/admin_user.go`,修改用户创建和更新请求的结构体(如果有),接收 `MaxConn`,并在保存到数据库时赋值。
```go
type CreateUserReq struct {
// ...
MaxConn *int `json:"maxConn"`
}
// 接收后:
if req.MaxConn != nil {
user.MaxConn = *req.MaxConn
}
```
在获取用户列表时,确保 `MaxConn` 返回给前端。
[x] **Step 2: 规则接口更新**
在 `go-backend/internal/http/handler/forward.go` 中,更新 `CreateForwardReq` 和 `UpdateForwardReq` 结构体,增加 `MaxConn`,并在创建/更新 Forward 时保存到数据库。
如果转发规则的 `MaxConn` 或相关信息改变,触发节点上的规则重载(重新下发服务)。这一步由于更改了数据库,复用现有的 `syncForwardServices` 就会带上最新的配置。
[x] **Step 3: 测试接口**
Run: 可以启动后使用 curl 测试。
[x] **Step 4: Commit**
```bash
cd go-backend
git add internal/http/handler/admin_user.go internal/http/handler/forward.go
git commit -m "feat: add maxConn to user and forward CRUD API"
cd ..
```
---
### Task 5: 前端 - 用户管理页面集成
**Files:**
- Modify: `vite-frontend/src/api/types.ts`
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/pages/users.tsx` (或者对应的用户管理页面文件)
[x] **Step 1: 类型更新**
在 `vite-frontend/src/api/types.ts` 中:
为 `UserApiItem` 和相关的 mutation payload 增加 `maxConn?: number` 属性。
[x] **Step 2: UI 修改**
在用户创建/编辑弹窗中,增加“最大连接数”输入框:
(假设使用 `@nextui-org/react` 的 `Input`)
```tsx
<Input
type="number"
label="最大并发连接数"
placeholder="0 或空表示不限制"
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
onValueChange={(val) => {
const num = parseInt(val, 10);
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
}}
/>
```
并在用户的表格列中展示 `最大连接数`(值为 0 显示“不限制”)。
[x] **Step 3: 运行 Vite 进行验证**
[x] **Step 4: Commit**
```bash
cd vite-frontend
git add src/api/types.ts src/api/index.ts src/pages/users.tsx
git commit -m "feat: add max conn UI to user management"
cd ..
```
---
### Task 6: 前端 - 转发规则页面集成
**Files:**
- Modify: `vite-frontend/src/pages/forward.tsx`
[x] **Step 1: 类型更新**
在 `api/types.ts` 中 `ForwardMutationPayload` 和 `ForwardApiItem` 中增加 `maxConn?: number`。
[x] **Step 2: UI 修改**
在 `vite-frontend/src/pages/forward.tsx` 的创建/编辑规则弹窗(在 "规则限速" 附近)增加“最大连接数”输入框:
```tsx
<Input
type="number"
label="最大并发连接数"
placeholder="0 或空表示不限制"
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
onValueChange={(val) => {
const num = parseInt(val, 10);
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
}}
description="此设置优先于用户的全局连接数限制。0 表示不限制(或使用用户的全局限制)。"
/>
```
如果是在列表/卡片中展示,可以增加一个小标签或者 Tooltip 显示其最大连接数设置。
[x] **Step 3: 验证**
在前端验证该功能能正确读写规则的连接限制字段。
[x] **Step 4: Commit**
```bash
cd vite-frontend
git add src/pages/forward.tsx src/api/types.ts
git commit -m "feat: add max conn UI to forward rules"
cd ..
```
@@ -1,162 +0,0 @@
# Floating Save Button Design
**Date:** 2026-04-01
**Issue:** https://github.com/Sagit-chu/flvx/issues/266
**Status:** Approved
## Overview
Add a Floating Action Button (FAB) to the config page (`vite-frontend/src/pages/config.tsx`) that appears when configuration changes are detected, allowing users to save without scrolling to the top.
## Requirements
From Issue #266:
1. **Default hidden**: FAB not visible when no config changes
2. **Show on change**: Auto-display when `hasChanges` becomes true
3. **Fixed position**: Suspended at bottom-right corner, does not scroll with page
4. **Mobile compatible**: Same behavior on desktop and mobile devices
## Design Decisions
### 1. Implementation Approach
**Inline FAB in config.tsx** (not a reusable component)
- Rationale: Current need is limited to config page only
- State management (`hasChanges`, `saving`) already exists in the page
- framer-motion patterns already established in project
- Avoids over-abstraction (YAGNI)
### 2. UI Structure
Position: `fixed bottom-6 right-6` (24px from viewport edges)
Visual layout:
```
┌──────────────────────────────────────┐
│ [页面内容,可滚动] │
│ │
│ [●] │ ← FAB (fixed position)
└──────────────────────────────────────┘
```
### 3. Button Appearance
- Shape: Circular (`w-12 h-12 rounded-full`)
- Color: Primary (matches existing save button)
- Icon: SaveIcon (already defined in config.tsx)
- Shadow: `shadow-lg` for visual hierarchy
- Style: Icon-only (no text label)
### 4. Animation
Using framer-motion with `AnimatePresence`:
| Phase | Properties |
|-------|------------|
| `initial` | `{ y: 100, opacity: 0 }` - starts below viewport |
| `animate` | `{ y: 0, opacity: 1 }` - slides up to position |
| `exit` | `{ y: 100, opacity: 0 }` - slides back down on hide |
Transition config:
```typescript
transition={{ type: "spring", damping: 20, stiffness: 300 }}
```
Spring parameters produce Material Design-like feel: smooth entrance, slight bounce settle.
### 5. Interaction Details
- **Click**: Calls existing `handleSave()` function
- **Loading state**: Button shows Spinner when `saving === true`
- **Hover**: Inherits Button component's primary color hover behavior
- **z-index**: `z-50` (above page content, below modals)
- **Prevent duplicate click**: Button disabled when `saving === true`
## Technical Implementation
### Code Location
File: `vite-frontend/src/pages/config.tsx`
### Required Imports
```typescript
import { AnimatePresence, motion } from "framer-motion";
```
### FAB Component Structure
```tsx
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
```
### Placement
Insert FAB at the end of the component, before the closing `</div>` (after all Cards and Modals).
### Dependencies
- framer-motion: Already installed (v11.18.2)
- Button: Already imported from `@/shadcn-bridge/heroui/button`
- SaveIcon: Already defined in config.tsx
## Behavior Matrix
| State | FAB Visibility | Button Enabled |
|-------|----------------|----------------|
| `hasChanges = false` | Hidden (not rendered) | N/A |
| `hasChanges = true, saving = false` | Visible, animating in | Yes |
| `hasChanges = true, saving = true` | Visible | No (loading) |
| Save success | Hidden (animating out) | N/A |
## Responsive Behavior
No special handling needed. `fixed bottom-6 right-6` works identically on:
- Desktop browsers
- Mobile browsers
- H5/WebView mode
The FAB maintains consistent 24px margin from viewport edges regardless of screen size.
## Edge Cases
1. **Multiple rapid toggles**: AnimatePresence handles gracefully - exit animation completes before new enter animation
2. **Page unload with unsaved changes**: Not addressed in this design (separate concern)
3. **FAB covers existing warning banner**: z-50 places FAB above the warning banner at line 1004-1013
## Testing Checklist
After implementation, verify:
- [ ] FAB appears when any config field is modified
- [ ] FAB slides up from bottom on appearance
- [ ] FAB slides down to bottom on disappearance
- [ ] FAB fixed position during page scroll
- [ ] FAB triggers save on click
- [ ] FAB shows spinner during save
- [ ] FAB disappears after successful save
- [ ] FAB works on mobile viewport
- [ ] FAB does not interfere with Modal dialogs
@@ -1,274 +0,0 @@
# GitHub 加速地址自定义配置设计
**日期**: 2026-04-01
**状态**: 待审核
**作者**: AI Assistant
## 概述
允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速功能。配置后,面板更新节点、生成安装命令以及安装脚本都使用配置的加速地址。
## 背景
当前 `gcode.hostcentral.cc` 硬编码在多个位置:
- `go-backend/internal/http/handler/upgrade.go` - 节点升级下载 URL
- `go-backend/internal/http/handler/mutations.go` - 节点安装命令生成
- `install.sh` - 节点安装脚本
- `panel_install.sh` - 面板安装脚本
用户无法自定义加速地址或关闭加速功能。
## 目标
1. 面板设置中支持配置加速开关和加速地址
2. 配置影响全部下载场景(面板端 + 安装脚本)
3. 安装脚本支持交互式询问加速配置
4. 面板生成的安装命令自动嵌入加速配置
## 影响范围
### 后端
- `go-backend/internal/http/handler/upgrade.go`
- `go-backend/internal/http/handler/mutations.go`
### 前端
- `vite-frontend/src/pages/config.tsx`
- `vite-frontend/src/config/site.ts`(缓存配置键)
### 安装脚本
- `install.sh`
- `panel_install.sh`
## 详细设计
### 1. 数据存储
使用现有 `vite_config` 表存储两个配置项:
| name | value | 说明 |
|------|-------|------|
| `github_proxy_enabled` | `"true"` / `"false"` | 是否开启加速,默认 `"true"` |
| `github_proxy_url` | URL 字符串 | 加速地址,默认 `"https://gcode.hostcentral.cc"` |
### 2. 后端 Handler 修改
#### upgrade.go
移除硬编码常量,新增辅助函数:
```go
// getGithubProxyConfig 获取 GitHub 加速配置
// 返回: (是否开启, 加速地址)
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = true // 默认开启
proxyURL = "https://gcode.hostcentral.cc" // 默认地址
if h == nil || h.repo == nil {
return
}
// 读取开启状态
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
// 读取加速地址
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
// 确保 URL 格式正确
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
// buildDownloadURL 构建下载地址
func (h *Handler) buildDownloadURL(version, arch string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("https://github.com/%s/releases/download/%s/gost-%s", githubRepo, version, arch)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
```
修改 `nodeUpgrade` 和 `nodeBatchUpgrade` 使用动态配置。
#### mutations.go
修改 `getNodeInstallCmd` 函数(约第 440-456 行):
```go
func (h *Handler) getNodeInstallCmd(w http.ResponseWriter, r *http.Request) {
// ... 现有逻辑 ...
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf(
"curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret,
)
} else {
cmd = fmt.Sprintf(
"curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret,
)
}
response.WriteJSON(w, response.OK(cmd))
}
```
### 3. 前端修改
#### config.tsx
在 `CONFIG_ITEMS` 数组中添加配置项(约第 87-158 行之后):
```typescript
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
```
在 `getInitialConfigs` 函数的 `configKeys` 数组中添加缓存键:
```typescript
"github_proxy_enabled",
"github_proxy_url",
```
### 4. 安装脚本修改
#### install.sh
在脚本开头添加配置变量和环境变量读取:
```bash
# 镜像加速配置(可由面板传入)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
```
修改 `maybe_proxy_url` 函数:
```bash
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}" # 移除末尾斜杠
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
```
在 `install_flux_agent` 函数开头添加交互式询问:
```bash
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
# 询问加速配置(如果未由面板传入)
if [[ -z "$PROXY_ENABLED" ]]; then
echo ""
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N) PROXY_ENABLED="false" ;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
;;
esac
fi
# ... 现有安装逻辑 ...
}
```
#### panel_install.sh
类似修改,在 `install_panel` 函数开头添加询问逻辑。
### 5. 配置缓存
#### site.ts
在配置缓存键列表中添加新键(如果需要前端缓存加速配置)。
## 默认行为
- `github_proxy_enabled`: 默认 `"true"`(开启加速)
- `github_proxy_url`: 默认 `"https://gcode.hostcentral.cc"`
## 测试要点
1. **后端 API 测试**:
- 未配置时使用默认值
- 配置后正确读取并应用
- 关闭加速后直连 GitHub
2. **前端 UI 测试**:
- Switch 开关正确切换
- 关闭加速时隐藏地址输入框
- 保存配置后正确持久化
3. **安装脚本测试**:
- 交互式询问正常工作
- 环境变量传入时跳过询问
- 加速关闭时直连 GitHub
4. **集成测试**:
- 面板生成安装命令正确包含加速配置
- 节点升级下载使用配置的加速地址
## 风险与缓解
| 风险 | 缓解措施 |
|------|----------|
| 用户输入无效加速地址 | 后端验证 URL 格式,前端添加格式提示 |
| 旧版本安装脚本不兼容 | 保持 `maybe_proxy_url` 函数签名不变,仅修改内部逻辑 |
| 配置缺失时行为不一致 | 在 `getGithubProxyConfig` 中提供合理的默认值 |
## 任务清单
- [ ] 后端:upgrade.go 修改
- [ ] 后端:mutations.go 修改
- [ ] 前端:config.tsx 添加配置项
- [ ] 脚本:install.sh 修改
- [ ] 脚本:panel_install.sh 修改
- [ ] 测试:验证功能正常
@@ -1,49 +0,0 @@
# FLVX 商业版白标授权功能设计方案
## 1. 目标
通过在设置面板中引入商业版激活码(License Key),允许已授权的用户去除前端页面的 FLVX 品牌标识,并使用自己的 App Name、Logo、Favicon 和隐藏版权信息,从而实现“白标”定制。
## 2. 功能范围
* **授权校验(服务端)**:提供一个激活码输入与验证的接口。初始版本采用**在线 Mock 验证**,后续可通过替换验证服务器地址实现真实的在线发卡与吊销逻辑。
* **配置存储(服务端)**:一旦授权成功,在数据库(如 `vite_config` 或现有的配置表)中记录授权状态(例如 `license_key`、`is_commercial` 等),并放开商业白标相关字段的写入权限(`app_name`, `app_logo`, `app_favicon`, `hide_footer_brand`)。
* **权限拦截(服务端)**:拦截未授权用户的请求,禁止他们更新相关的品牌字段。
* **前端 UI(客户端)**:
* 在配置页面(或单独的“授权/个性化” Tab)提供激活码输入框。
* 如果未激活:界面仅展示默认品牌配置,并提示“需要商业授权以解锁自定义品牌”。
* 如果已激活:展示站名、Logo、Favicon 的上传和替换表单,提供隐藏“Powered by FLVX”脚标的开关。
## 3. 架构设计
### 3.1 数据库/配置结构
扩展配置系统中的以下字段:
* `license_key` (String):存储用户激活的商业版密钥。
* `is_commercial` (String/Boolean):标识是否为合法的商业授权状态("true" 或 "false")。
* `hide_footer_brand` (String/Boolean):是否隐藏底部的 FLVX 信息。
注意:现有的 `app_name`, `app_logo`, `app_favicon` 字段将收紧修改权限。
### 3.2 服务端 API 变更
* **新增 API `POST /api/license/activate` (或将逻辑集成到现有配置修改接口)**:
* 接收 `{ "license_key": "FLVX-xxxx" }`。
* **Mock 逻辑**:如果是 `FLVX-` 开头则视为合法。
* 合法则更新系统配置,设置 `license_key` 并将状态标为 `is_commercial: "true"`。
* **修改 API 权限校验(如保存系统设置的接口)**:
* 当接收到更新 `app_name`、`app_logo`、`app_favicon`、`hide_footer_brand` 的请求时,检查当前系统中的 `is_commercial` 状态。
* 如果未授权且尝试修改白标字段,返回错误(如 `403 Forbidden`)提示需要商业授权。
### 3.3 前端设计
* **授权卡片**:在全局设置(Settings / Config)页加入「商业版授权」或「个性化」区块。
* **表单按需显示**:使用配置中的 `is_commercial === "true"` 来控制相关表单组件的展示:
* 如果未授权,白标字段(Logo、Favicon、App Name、Hide Footer)不可修改(呈 Disabled)或覆盖了一层“锁”图标。
* 底部 Footer 组件读取 `hide_footer_brand === "true"` 决定是否渲染 `Powered by FLVX`。
* **全局状态同步**:当用户激活或上传完 Logo 后,通过现有的 `syncLogo` / `syncFavicon` 等机制全局刷新外观。
## 4. 安全与降级
* **本地缓存失效**:如果后台在线验证服务器(未来)判断该 key 被吊销,可以在后续获取 config 的接口中重置白标配置为空,强制回退到默认 FLVX 主题。
* **接口防绕过**:所有跟商业字段相关的变更,必须经过后端 API 的鉴权,确保纯前端绕过是无效的。
## 5. 测试策略
1. **输入非法激活码**,提示错误,白标设置项仍被锁定。
2. **输入合法激活码 (`FLVX-...`)**,提示成功,白标设置项解锁。
3. **成功后上传 Logo 和修改站名**,刷新页面,前端应正常应用新配置且没有 FLVX 标记。
4. **接口测试**:在未授权状态下,尝试强行通过 API 更新 `app_logo`,接口应返回权限不足。
@@ -1,59 +0,0 @@
# FLVX 商业版 Keygen.sh 授权集成设计方案
## 1. 目标
使用 [Keygen.sh](https://keygen.sh/) 替换当前 FLVX 中基于 Mock 的商业版授权验证逻辑。通过接入 Keygen.sh,实现安全、可控的许可证分发、设备绑定(防止一码多用)、定期验证以及远程吊销功能,为 FLVX 的商业化白标功能提供生产级支持。
## 2. Keygen.sh 核心概念映射
* **Account (账户)**:您在 Keygen 注册的商户账号。
* **Product (产品)**:在 Keygen 中创建一个名为 `FLVX Panel` 的产品。
* **Policy (策略)**:定义授权规则。例如,创建一个 `White-Label Policy`,限制每个 License 只能绑定 **1 个 Machine**(即一个 FLVX 面板实例),并可配置有效期(如按年订阅或永久有效)。
* **License (许可证)**:发给客户的激活码(Key),格式可自定义(如 `FLVX-XXXX-XXXX`)。
* **Machine (机器/设备)**:运行 FLVX 的具体服务器或面板实例。为了防止一码多开,FLVX 激活时需要向 Keygen 注册一台 Machine。
## 3. 架构设计与集成流程
### 3.1 唯一设备标识 (Machine Fingerprint)
为了在 Keygen 中标识不同的 FLVX 面板,FLVX 后端需要生成并持久化一个唯一的机器指纹(Fingerprint)。
* **生成时机**:FLVX 首次启动或首次激活时,生成一个 UUID v4。
* **存储**:保存在数据库 `vite_config` 表中,键名为 `machine_fingerprint`。
### 3.2 激活流程 (License Activation)
当用户在前端输入激活码并点击“激活”时:
1. **FLVX 后端验证 Key**:调用 Keygen API `POST /v1/accounts/{account}/licenses/actions/validate-key`,传入 `key`。
2. **检查 License 状态**:如果返回 `valid: true`,说明 License 合法且未过期。
3. **激活 Machine (设备绑定)**:
* 调用 Keygen API `POST /v1/accounts/{account}/machines`。
* 关联刚才验证的 `licenseId`,并传入 FLVX 的 `machine_fingerprint`。
* *异常处理*:如果该 License 已绑定了其他 Machine(达到 Policy 上限),Keygen 会报错,FLVX 后端需返回“该授权码已在其他设备使用”。
4. **持久化状态**:激活成功后,在本地数据库保存 `license_key`、`is_commercial: "true"`,以及从 Keygen 返回的额外信息(如过期时间 `license_expiry`)。
### 3.3 定期心跳与验证 (Periodic Validation)
为了防止用户激活后断网或通过修改数据库绕过,以及实现**远程吊销**:
* **定时任务**:FLVX 后端增加一个后台协程(如每天运行一次,或每 12 小时运行一次)。
* **验证逻辑**:调用 Keygen API 验证当前的 `license_key` 和 `machine_fingerprint`。
* **吊销/过期处理**:如果 Keygen 明确返回 License 已吊销(Suspended/Revoked/Banned)或已过期,或者当前 Machine 不再属于该 License,FLVX 后端需将 `is_commercial` 强制设为 `"false"`,并清空本地缓存,恢复官方品牌展示。
* **宽限期 (Grace Period)**:考虑到用户服务器可能偶尔网络不通,如果请求 Keygen 超时或失败,不应立刻吊销。可设置一个宽限期(如连续 3 天请求失败才降级)。
## 4. 后端 API 改造计划 (`go-backend`)
### 4.1 新增环境变量/配置
* `KEYGEN_ACCOUNT_ID`: 您的 Keygen 账户 ID(打包时可硬编码,或作为全局环境变量)。
* (可选)`KEYGEN_PRODUCT_TOKEN` 或仅使用 License Key 进行验证(取决于 Keygen 验证方式的选择,推荐直接使用 License Key 进行无状态验证)。
### 4.2 改造 `/api/v1/license/activate`
* 引入 HTTP 客户端向 `api.keygen.sh` 发起请求。
* 实现上述提到的 Validate Key 和 Activate Machine 两步走逻辑。
* 返回具体的错误信息给前端(例如:“授权码不存在”、“授权码已过期”、“激活设备数达上限”)。
## 5. 前端改造计划 (`vite-frontend`)
前端在目前的 UI 基础上几乎不需要大改,只需配合后端的增强:
1. **展示过期时间**:如果后端返回了 `license_expiry`,可以在“商业版授权”卡片中展示“授权有效期至:YYYY-MM-DD”。
2. **错误提示优化**:透传后端返回的 Keygen 验证错误,给予用户明确的指引。
3. **解绑/停用功能(可选)**:未来可增加“停用授权”按钮,调用后端接口在 Keygen 中删除 Machine 绑定,以便用户将 License 迁移到新的服务器。
## 6. 实施步骤建议
1. 在 Keygen.sh 注册账号,创建 Product 和 Policy,生成测试用的 License Key。
2. 在 FLVX 的 `go-backend` 中新建一个 `pkg/keygen` 或 `internal/license` 包,封装 Keygen API 的调用(Validate, Activate Machine)。
3. 修改现有的 `licenseActivate` 接口,接入真正的验证逻辑。
4. 添加定期验证的 Cron Job。
5. 测试激活、吊销、过期、断网等各种场景。
@@ -1,36 +0,0 @@
# Announcement Popup Notification Design
## Overview
This feature implements a popup notification modal for important dashboard announcements to ensure users see them immediately, addressing GitHub Issue #169.
## Requirements
1. Automatic display of a popup modal when opening the dashboard page if a new/updated announcement exists.
2. Includes a "Don't show again" option to remember the user's choice to dismiss it.
3. Smart triggering: Only pops up for *new* or *updated* announcements.
4. Support Markdown formatting for the announcement content.
5. Retain the existing permanent top banner as a fallback.
## Backend Changes (Go)
The `/api/v1/announcement/get` API currently only returns `content` and `enabled`. It must be updated to return the timestamp of the last update to enable the frontend to detect changes.
1. **Repository (`internal/store/repo/repository.go`)**: Ensure `GetAnnouncement` retrieves `UpdatedTime` (or falls back to `CreatedTime`).
2. **Handler (`internal/http/handler/handler.go`)**: Modify `getAnnouncement` to include an `update_time` (int64) field in its JSON response.
## Frontend Changes (Vite/React/Tailwind)
1. **API Interface (`src/api/index.ts`)**:
* Update `AnnouncementData` to include `update_time: number`.
2. **Storage Mechanism**:
* Use browser `localStorage` to persist the user's view state. Key: `flvx_announcement_seen_time`.
3. **UI Component (`AnnouncementModal`)**:
* Create a new modal component for the dashboard.
* The modal content will render the markdown of the announcement.
* It will feature two primary actions:
* **"Close"**: Closes the modal temporarily for this session (does NOT update `localStorage`). It will pop up again on the next page load.
* **"Don't show again"**: Closes the modal AND sets `localStorage.setItem('flvx_announcement_seen_time', announcement.update_time)`.
4. **Integration (`src/pages/dashboard.tsx` & `use-dashboard-data.ts`)**:
* Add state to manage the modal visibility (e.g., `isAnnouncementModalOpen`).
* On data load, compare the fetched `update_time` with the stored `flvx_announcement_seen_time`. If the fetched time is greater (or if no stored time exists), set `isAnnouncementModalOpen(true)`.
## Error Handling and Edge Cases
* If `localStorage` is unavailable or throws an error (e.g., Private Browsing mode restrictions), the modal may show repeatedly. The code should safely catch `localStorage` access errors.
* If `update_time` is missing from an old database record, the backend should gracefully fall back to the creation time or a safe default (like 0) to ensure the logic doesn't break.
@@ -1,60 +0,0 @@
# Flvx iOS 26 Liquid Glass UI Redesign Spec
## 1. Overview
This document specifies the comprehensive UI/UX redesign of the Flvx frontend using an "Apple iOS 26 Liquid Glass" design language. The goal is to elevate the visual quality of the entire application, making it modern, spatially aware, and highly legible through extensive use of blur, translucency, squircle borders, and semantic contrast.
## 2. Scope
The redesign covers 100% of the frontend routing pages and overlay components under `vite-frontend/src/pages/` and global UI modules:
- Dashboard (`dashboard.tsx`)
- Node Management (`node.tsx`)
- Tunnel & Rule Configurations (`tunnel.tsx`, `forward.tsx`)
- System Monitor (`monitor.tsx`)
- User Management (`user.tsx`)
- Speed Limit Management (`limit.tsx`)
- Group Management (`group.tsx`)
- Panel Sharing (`panel-sharing.tsx`)
- Global Settings & Config (`config.tsx`, `settings.tsx`)
- Profile & Change Password (`profile.tsx`, `change-password.tsx`)
- All related Modals, Drawers, and floating UI (e.g., "Create Node", "Add Rule" forms).
## 3. Design System & Tokens
The new UI replaces traditional solid-color borders and flat surfaces with the following spatial design tokens:
### 3.1. Corner Radii (Squircles)
- **Outer Shell / Viewports**: 32px (`rounded-3xl` equivalent)
- **Cards / Containers**: 24px (`rounded-2xl`)
- **Buttons / Inputs**: 16px (`rounded-xl` or `rounded-full`)
- **Badges / Tags**: 6px or fully rounded.
### 3.2. Backgrounds & Blurs
- **Global Background**: A mesh gradient blending soft pinks and blues (`#ff9a9e`, `#fecfef`, `#a1c4fd`, `#c2e9fb`).
- **Glass Base (Primary Containers)**: `backdrop-blur-3xl` with an ultra-thin white overlay (`rgba(255, 255, 255, 0.6)` or `#ffffff99`).
- **Glass Inner Glow (Borders)**: 1px solid `rgba(255, 255, 255, 0.8)` (`#ffffffcc`).
- **Modals Background Overlay**: 30% black overlay (`#0000004d`).
### 3.3. Semantic Colors
- **Brand / Active / Primary / TCP**: Blue `#007aff`
- **Healthy / Success / Online**: Green `#34c759`
- **Warning / Wait / UDP**: Orange `#ff9500`
- **Danger / Offline / Delete**: Red `#ff3b30`
- **Secondary / Purple / Data**: Purple `#af52de`
- **Text**: Primary (`#1d1d1f`), Secondary (`#86868b`).
### 3.4. Elevation & Shadow
- Soft, highly diffused drop shadows rather than sharp lines: e.g., `box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1)`.
## 4. Implementation Strategy
We will implement the redesign systematically across the React + TailwindCSS + shadcn/HeroUI stack:
1. **CSS Variables / Tailwind Config**: Inject the new Liquid Glass design tokens (colors, extended radiuses, customized backdrop blurs, box shadows) into `tailwind.config.js` and `globals.css`.
2. **Global App Shell**: Update the root layout (`index.tsx` or main `App` layout) to host the dynamic mesh gradient background and the new translucent sidebar.
3. **Component Re-styling**:
- Override HeroUI default card, input, and modal styles using custom `classNames`.
- Update `MetricCard`, `PageEmptyState`, `PageLoadingState`, and other base components to support the `glass_card` spec.
4. **Page-by-Page Integration**: Rewrite the JSX of each page to utilize the new layout structure, ensuring all existing interactive state and API logic is seamlessly preserved.
5. **Modal System Update**: Apply the transparent `#0000004d` overlay and 480px width glassy card style to all global dialogs.
## 5. Success Criteria
- [ ] No regression in business logic; all forms, interactions, and data rendering operate exactly as before.
- [ ] The visual system consistently employs the `glass_bg`, `glass_card`, and corresponding squircle radiuses across 100% of the UI.
- [ ] Modals and Overlays correctly blur the background mesh gradient.
- [ ] All responsive layouts appropriately wrap the card components on smaller displays.
BIN
View File
Binary file not shown.
-38
View File
@@ -1,38 +0,0 @@
# go-backend
Admin API for FLVX. Go + net/http + GORM (SQLite/PostgreSQL).
## Structure
| Dir | Role |
|-----|------|
| `cmd/paneld/main.go` | Entry point, HTTP server + WebSocket |
| `internal/http/router.go` | Route registration (`http.ServeMux`) + middleware chain |
| `internal/http/handler/` | API handlers |
| `internal/http/middleware/` | JWT, CORS, logging, recover |
| `internal/http/response/` | JSON envelope helpers |
| `internal/store/model/model.go` | All GORM models (single file) |
| `internal/store/repo/` | Repository layer (never access DB directly) |
| `internal/auth/` | Auth logic |
| `tests/contract/` | Integration tests |
## Conventions
- **Auth**: raw JWT in `Authorization` header — no `Bearer` prefix.
- **API envelope**: `{code, msg, data, ts}`, code 0 = success.
- **Repository pattern**: handlers call repo methods, never `repo.DB()` directly.
- **GORM**: `TableName()` on every model (GORM pluralizes by default).
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
- **SQLite**: `MaxOpenConns(1)`, WAL mode, `busy_timeout=5000`.
- **Schema**: created via `autoMigrateAll()` at startup, no hand-written DDL.
- **PostgreSQL**: set `DB_TYPE=postgres` and `DATABASE_URL` env vars.
- **Config**: all from environment variables.
## Commands
```bash
go run ./cmd/paneld # SERVER_ADDR defaults to :6365
make build
go test ./... # includes contract tests
go test ./tests/contract/... # contract tests only
```
-19
View File
@@ -1,19 +0,0 @@
FROM golang:1.25-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/paneld /app/paneld
ENV SERVER_ADDR=:6365
EXPOSE 6365
ENTRYPOINT ["/app/paneld"]
-536
View File
@@ -1,536 +0,0 @@
# 数据库 GORM ORM 迁移计划
**创建时间:** 2026-02-15
**更新时间:** 2026-02-17 (实施:完成 P1 + P2 + P3 + P5(Repo 查询层 + schema 收尾) + 测试/构建收尾)
**分支:** main (commit e5e22ba)
**状态:** 基本完成(保留 4 处 PG 序列修复 DDL `Exec`)
---
## 一、现状分析
### 1.1 迁移前架构 (已归档)
项目原使用 `database/sql` + 手写 raw SQL,通过 `internal/store/db.go` 中的运行时 SQL 重写层实现 SQLite/PostgreSQL 双数据库兼容。
| 组件 | 行数 | 角色 | 当前状态 |
|------|------|------|----------|
| `store/db.go` | ~520 | SQL 方言重写层 | **已删除** |
| `store/sqlite/repository.go` | ~3118 | Repository 查询方法 | **已重写为 store/repo/** |
| `handler/mutations.go` | ~3748 | Handler 内直接写 raw SQL | **已迁移到 repo(生产 SQL=0)** |
| `handler/handler.go` | ~1283 | 部分方法用 `repo.DB()` | **大部分已迁移** |
| `handler/federation.go` | ~若干 | Federation 相关 SQL | **已迁移到 repo** |
| `handler/control_plane.go` | ~若干 | 控制面相关 SQL | **已迁移到 repo** |
| `handler/flow_policy.go` | ~若干 | 流量策略相关 SQL | **已迁移到 repo** |
| `handler/jobs.go` | ~若干 | 后台任务相关 SQL | **已迁移到 repo** |
| `store/postgres/` | 目录 | PostgreSQL 专用 schema/data | **已删除** |
### 1.2 痛点 (迁移目标)
1. ~~**双 Schema 维护**~~:已通过 AutoMigrate 解决
2. ~~**SQL 重写层复杂**~~:db.go 已删除
3. ~~**handler 直接写 SQL**~~:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` 已清零(测试代码除外)
4. ~~**无类型安全**~~:repo 业务查询已 GORM 化;剩余 4 处为 PG 序列修复 DDL `Exec`(设计保留)
5. ~~**模型定义分散**~~:已集中到 model/model.go
---
## 二、方案:引入 GORM ORM(全面重写)
### 2.1 方案变更说明
原计划为 **方案 D(扩展现有 DDL 重写层)**,现变更为 **方案 A(GORM 全面重写)**。
### 2.2 选择 GORM 的理由
1. Go 生态最成熟的 ORM,社区庞大,文档完善
2. 原生支持 SQLite + PostgreSQL 双数据库,自动处理方言差异
3. AutoMigrate 消除双 schema 维护,自动处理 AUTOINCREMENT ↔ SERIAL 等
4. 类型安全的模型定义,编译期检查字段映射
5. 内置事务管理(closure pattern 自动 rollback/commit)
6. 自动处理 `"user"` 保留字引号
### 2.3 GORM 驱动选择
| 数据库 | 驱动 | 包 | 备注 |
|--------|------|-----|------|
| SQLite | modernc.org/sqlite (CGO-free) | `github.com/glebarez/sqlite` | 纯 Go,无需 CGO |
| PostgreSQL | pgx/v5 | `gorm.io/driver/postgres` | 默认使用 pgx |
> **注意**:标准 `gorm.io/driver/sqlite` 依赖 CGO,必须使用 `glebarez/sqlite` 包装器。
### 2.4 核心设计原则
1. **Model 集中定义**:所有 GORM Model 在 `internal/store/model/` 包中
2. **Repository 模式保留**:Repository struct 持有 `*gorm.DB`,对外方法签名尽量不变
3. **Handler 不直接操作 DB**:所有数据库操作必须封装在 Repository 方法中
4. **AutoMigrate 替代 schema.sql**:启动时自动迁移,不再维护手写 DDL
5. **保留 PG 序列修复**:pgloader 迁移场景仍需 `ensurePostgresIDDefaults()`
6. **Package 重命名**:`store/sqlite` → `store/repo`
---
## 三、Model 设计
### 3.1 GORM 类型映射
| Go 类型 | GORM 行为 | PostgreSQL | SQLite |
|---------|-----------|------------|--------|
| `int64` + `primaryKey` | 自增主键 | `bigserial` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
| `int64` | 64位整数 | `bigint` | `integer` (SQLite 自动 64位) |
| `int` | 整数 | `integer` | `integer` |
| `float64` | 浮点 | `double precision` | `real` |
| `string` + `size:100` | 变长字符 | `varchar(100)` | `varchar(100)` |
| `string` (无 size) | 文本 | `text` | `text` |
| `sql.NullInt64` | 可空整数 | `bigint NULL` | `integer NULL` |
| `sql.NullString` | 可空文本 | `text NULL` | `text NULL` |
### 3.2 表清单(21 张表)
| 表名 | Model | 特殊处理 |
|------|-------|----------|
| `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) |
| `forward` | `Forward` | |
| `forward_port` | `ForwardPort` | |
| `node` | `Node` | |
| `speed_limit` | `SpeedLimit` | |
| `statistics_flow` | `StatisticsFlow` | |
| `tunnel` | `Tunnel` | |
| `chain_tunnel` | `ChainTunnel` | |
| `user_tunnel` | `UserTunnel` | 复合唯一索引 (user_id, tunnel_id) |
| `tunnel_group` | `TunnelGroup` | |
| `user_group` | `UserGroup` | |
| `tunnel_group_tunnel` | `TunnelGroupTunnel` | 复合唯一索引 |
| `user_group_user` | `UserGroupUser` | 复合唯一索引 |
| `group_permission` | `GroupPermission` | 复合唯一索引 |
| `group_permission_grant` | `GroupPermissionGrant` | 复合唯一索引 |
| `vite_config` | `ViteConfig` | name 唯一 |
| `peer_share` | `PeerShare` | token 唯一 |
| `peer_share_runtime` | `PeerShareRuntime` | reservation_id, resource_key 唯一 |
| `federation_tunnel_binding` | `FederationTunnelBinding` | 复合唯一索引 + resource_key 唯一 |
| `announcement` | `Announcement` | |
| `schema_version` | `SchemaVersion` | |
---
## 四、详细实施步骤
### 阶段 1:基础设施 — 添加依赖 + 定义 Model ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 1.1 | `go get gorm.io/gorm gorm.io/driver/postgres github.com/glebarez/sqlite` | `go.mod` | ✅ |
| 1.2 | 创建 `internal/store/model/model.go`,定义全部 21 个表 Model | 新文件 | ✅ |
| 1.3 | 为 `user` 表添加 `TableName()` 处理 PG 保留字 | model.go | ✅ |
| 1.4 | 为复合唯一索引的表添加 GORM 索引 tag | model.go | ✅ |
| 1.5 | 将 Backup 相关 struct 也迁移到 model/ | model.go | ✅ |
| 1.6 | 验证 `go build ./...` 编译通过 | - | ✅ |
### 阶段 2:GORM DB 初始化 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 2.1 | 修改 Repository struct,`*store.DB` → `*gorm.DB` | repository.go | ✅ |
| 2.2 | 重写 `Open()` — 用 `glebarez/sqlite` 打开 SQLite | repository.go | ✅ |
| 2.3 | 重写 `OpenPostgres()` — 用 `gorm.io/driver/postgres` 打开 PG | repository.go | ✅ |
| 2.4 | 用 `db.AutoMigrate()` 替代 `bootstrapSchema()` | repository.go | ✅ |
| 2.5 | 实现种子数据逻辑(FirstOrCreate 替代 data.sql) | repository.go | ✅ |
| 2.6 | 保留并适配 `ensurePostgresIDDefaults()`(用 `db.Exec()`) | repository.go | ✅ |
| 2.7 | 保留并适配 `migrateSchema()` 增量迁移 | repository.go | ✅ |
| 2.8 | `DB()` 方法返回 `*gorm.DB` | repository.go | ✅ |
| 2.9 | SQLite 连接池设置 `MaxOpenConns(1)` 防锁 | repository.go | ✅ |
### 阶段 3:重写 repository 查询方法 ⚠️ ~97% 完成
将所有 raw SQL 查询替换为 GORM 链式调用。
> **2026-02-16 审计**:基础 CRUD 查询已 GORM 化,但 mutation、JOIN 查询、import/export 仍大量使用 raw SQL。
> **2026-02-17 更新**:已完成 `repository_mutations.go`、Import、以及 `repository_federation/control/flow` 查询层 GORM 化;`repository.go` 中 Raw 已清零,当前仅保留 4 处 PG 序列修复 DDL `Exec`。
| 步骤 | 任务 | 方法数 | 状态 |
|------|------|--------|------|
| 3.1 | 用户查询:GetUserByUsername, GetUserByID, UsernameExists* 等 | ~5 | ✅ |
| 3.2 | 配置查询:GetConfigByName, ListConfigs, UpsertConfig | ~3 | ✅ |
| 3.3 | 公告查询:GetAnnouncement, UpsertAnnouncement | ~2 | ✅ |
| 3.4 | 节点查询:GetNodeBy*, ListNodes, UpdateNode* | ~6 | ✅ |
| 3.5 | 隧道查询:ListTunnels, ListTunnelGroups 等 (含 chain_tunnel 关联) | ~5 | ✅ |
| 3.6 | 转发查询:ListForwards, resolveForwardIngress | ~3 | ✅ |
| 3.7 | 用户隧道:GetUserPackageTunnels, GetUserPackageForwards | ~3 | ✅ |
| 3.8 | 统计/限速:GetStatisticsFlows, ListSpeedLimits, AddFlow | ~4 | ✅ |
| 3.9 | 分组查询:ListUserGroups, ListGroupPermissions 等 | ~4 | ✅ |
| 3.10 | PeerShare 全部方法 (CRUD + Runtime) | ~15 | ✅ |
| 3.11 | FederationTunnelBinding 全部方法 | ~4 | ✅ (Upsert 用 clause.OnConflict) |
| 3.12 | Export 全部方法 | ~10 | ✅ |
| 3.13 | Import 全部方法 | ~10 | ✅ 已全部改为 GORM `Clauses(clause.OnConflict)`(见 §9.6) |
| **3.14** | **repository_mutations.go 全部方法 (~40 个)** | **~40** | **✅ 已全量改为 GORM 链式调用(见 §9.3)** |
| **3.15** | **repository_federation.go 查询方法** | **~8** | **✅ 已全部改为 GORM 链式调用** |
| **3.16** | **repository_control.go 复杂查询** | **~5** | **✅ 已全部改为 GORM 链式调用** |
| **3.17** | **repository_flow.go 查询方法** | **~5** | **✅ 已全部改为 GORM 链式调用** |
| **3.18** | **Jobs 查询方法 (repository.go 尾部)** | **~8** | **✅ 已 GORM 化** |
### 阶段 4:消除 handler 中直接 SQL — 提取为 Repository 方法 ✅ 已完成
> **2026-02-16 审计**:handler 中的 SQL 已大部分提取到 repo 层,但这些 repo 方法本身仍使用 raw SQL(见阶段 3)。
> **2026-02-17 更新**:`mutations.go` 直接 `tx.Exec`/`tx.Raw` 已从 27 处降至 0 处(生产代码),详见 §9.4。
mutations.go 和其他 handler 文件中大量直接操作 `h.repo.DB()` 执行 raw SQL,需要:
1. 将 SQL 逻辑提取为 Repository 方法
2. Handler 只调用 Repository 方法
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 4.1 | 用户 CRUD:userCreate, userUpdate, userDelete, userResetFlow | mutations.go | ✅ 已提取到 repo 方法 |
| 4.2 | 节点 CRUD:nodeCreate, nodeUpdate, nodeDelete, nodeBatch* | mutations.go | ✅ 已提取到 repo 方法 |
| 4.3 | 隧道 CRUD:tunnelCreate, tunnelUpdate, tunnelDelete, tunnelBatch* | mutations.go | ✅ tunnelCreate/Update 的 SQL 已下沉 repo |
| 4.4 | 转发 CRUD:forwardCreate, forwardUpdate, forwardDelete, forwardBatch* | mutations.go | ✅ 已提取到 repo (CreateForwardTx 等) |
| 4.5 | 限速 CRUD:speedLimitCreate, speedLimitUpdate, speedLimitDelete | mutations.go | ✅ 已提取到 repo 方法 |
| 4.6 | 分组 CRUD:所有 group* 方法 | mutations.go | ✅ 成员同步/权限管理 SQL 已下沉 repo |
| 4.7 | 用户隧道:userTunnelAssign, userTunnelRemove, userTunnelUpdate | mutations.go | ✅ 已提取到 repo 方法 |
| 4.8 | handler.go 中的直接 SQL (openAPISubStore 等) | handler.go | ✅ 已迁移(含 nil 检查清理) |
| 4.9 | federation.go 中的 raw SQL | federation.go | ✅ 已提取到 repo_federation.go |
| 4.10 | control_plane.go 中的 raw SQL | control_plane.go | ✅ 已提取到 repo_control.go |
| 4.11 | flow_policy.go 中的 raw SQL | flow_policy.go | ✅ 已提取到 repo_flow.go |
| 4.12 | jobs.go 中的 raw SQL | jobs.go | ✅ 已提取到 repo 方法(含 nil 检查清理) |
### 阶段 5:清理旧代码 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 5.1 | 删除 `internal/store/postgres/` 整个目录 | 目录删除 | ✅ |
| 5.2 | 删除 `internal/store/sqlite/sql/` 目录 | 目录删除 | ✅ |
| 5.3 | 删除 `internal/store/db.go` SQL 重写层 | 文件删除 | ✅ |
| 5.4 | 删除 `internal/store/db_test.go` | 文件删除 | ✅ |
| 5.5 | 清理 repository.go 中不再需要的 embed 指令 | 清理 | ✅ |
### 阶段 6:Package 重命名 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 6.1 | `internal/store/sqlite/` → `internal/store/repo/` | 目录重命名 | ✅ |
| 6.2 | 更新所有 import 路径:`store/sqlite` → `store/repo` (13处) | 全局替换 | ✅ |
### 阶段 7:测试 + 验证 ⚠️ 部分完成
| 步骤 | 任务 | 状态 |
|------|------|------|
| 7.1 | 更新所有现有测试适配 GORM | ✅ 测试已适配 (使用 repo.DB() 做数据准备) |
| 7.2 | `go test ./...` 全部通过 | ✅ 已通过(含 `internal/http/handler`、`tests/contract`) |
| 7.3 | `make build` 构建成功 | ✅ 已通过 |
### 阶段 8:文档更新 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 8.1 | 更新 `go-backend/AGENTS.md` — 移除 "DO NOT USE ORM",记录 GORM 规范 | AGENTS.md | ✅ |
| 8.2 | 更新根 `AGENTS.md` | AGENTS.md | ✅ |
| 8.3 | 更新 `handler/AGENTS.md` | AGENTS.md | ✅ |
---
## 五、GORM 使用规范
### 5.1 查询模式
```go
// 单条查询 - 未找到返回 nil, nil (保持现有语义)
var user model.User
err := r.db.Where("id = ?", id).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
// 列表查询
var users []model.User
err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error
// 创建
err := r.db.Create(&user).Error
// 更新 (部分字段)
err := r.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
"user": username, "flow": flow, "updated_time": now,
}).Error
// 事务 (closure pattern - 自动 rollback/commit)
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", id).Delete(&model.Forward{}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Delete(&model.User{}).Error
})
// 原生 SQL (仅用于复杂查询和 PG 特有操作)
r.db.Exec("SELECT setval(?::regclass, ?, ?)", seqRef, maxID, true)
```
### 5.2 关键注意事项
1. **user 保留字**:通过 `TableName()` 返回 `"user"`,GORM 自动处理引号
2. **SQLite MaxOpenConns**:必须设为 1 防止 "database locked"
3. **SQLite WAL 模式**:DSN 中配置 `_pragma=journal_mode(WAL)`
4. **不要用 `type:jsonb`**:SQLite 不支持,用 `serializer:json`
5. **不要用 `type:serial`**:让 GORM 从 `primaryKey` 自动推断
6. **AutoMigrate 在 SQLite 中使用 copy-swap-drop**:大表慎用
---
## 六、影响范围
### 需要修改的文件
| 文件 | 修改类型 | 描述 | 当前状态 |
|------|----------|------|----------|
| `go.mod` / `go.sum` | 修改 | 添加 GORM + 驱动依赖 | ✅ |
| `internal/store/model/model.go` | **新增** | 全部 21 个 GORM Model | ✅ |
| `internal/store/repo/repository.go` | **重写** | 全部查询 GORM 化 | ⚠️ 业务查询已 GORM;仅剩 PG 序列修复 DDL `Exec` 4 处 |
| `internal/store/repo/repository_mutations.go` | **重写** | Mutation helpers | ✅ 全量 GORM(Raw=0) |
| `internal/store/repo/repository_federation.go` | **重写** | Federation 查询 | ✅ 已 GORM 化(Raw=0) |
| `internal/store/repo/repository_control.go` | **重写** | 控制面查询 | ✅ 已 GORM 化(Raw=0) |
| `internal/store/repo/repository_flow.go` | **重写** | 流量/转发查询 | ✅ 已 GORM 化(Raw=0) |
| `internal/http/handler/mutations.go` | **重写** | 全部 CRUD 提取到 repo | ✅ 生产代码 `tx.Exec/tx.Raw` = 0 |
| `internal/http/handler/handler.go` | 修改 | 更新 import、移除直接 SQL | ✅ (仅剩 nil check) |
| `internal/http/handler/federation.go` | 修改 | GORM 替代 raw SQL | ✅ |
| `internal/http/handler/control_plane.go` | 修改 | GORM 替代 raw SQL | ✅ |
| `internal/http/handler/flow_policy.go` | 修改 | GORM 替代 raw SQL | ✅ |
| `internal/http/handler/jobs.go` | 修改 | GORM 替代 raw SQL | ✅ (仅剩 nil check) |
| `internal/ws/server.go` | 修改 | 更新 import | ✅ |
| `internal/app/app.go` | 修改 | 更新 import | ✅ |
| `internal/store/postgres/` | **删除** | 不再需要 | ✅ |
| `internal/store/db.go` | **删除** | GORM 自动处理方言 | ✅ |
| `internal/store/db_test.go` | **删除** | 旧重写层测试 | ✅ |
| `internal/store/sqlite/sql/` | **删除** | AutoMigrate 替代 | ✅ |
| `tests/contract/*.go` | 修改 | 适配 GORM | ✅ |
| `AGENTS.md` (3处) | 更新 | 反映新架构 | ✅ |
### 不需要修改的文件
- `internal/http/router.go` — 路由不变
- `internal/config/config.go` — 配置不变
- `internal/auth/` — 认证不变
- `internal/security/` — 加密不变
- `internal/http/middleware/` — 中间件不变
- `internal/http/response/` — 响应格式不变
- `Dockerfile`, `Makefile` — 构建不变
---
## 七、风险与缓解
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|----------|
| GORM AutoMigrate SQLite/PG 行为差异 | 中 | 高 | 先写 Model 验证双数据库 AutoMigrate |
| handler 中散落 raw SQL 遗漏 | 中 | 高 | 全局搜索 `.Exec(`, `.Query(`, `.QueryRow(` |
| 事务语义变化 | 低 | 中 | 逐方法对比旧代码事务边界 |
| 大量代码变更导致回归 | 高 | 高 | 分阶段提交,每阶段 `go test` |
| GORM 性能开销 | 低 | 低 | 此场景下可忽略 |
| SQLite "database locked" | 中 | 高 | `MaxOpenConns(1)` + WAL 模式 |
---
## 八、迁移顺序原则
1. **先 Model 后查询**:确保 AutoMigrate 双数据库通过
2. **先 Repository 后 Handler**:Handler 依赖 Repository
3. **先核心后边缘**:User → Node → Tunnel → Forward → 分组 → Federation
4. **每步编译**:每完成一组方法确保 `go build ./...` 通过
5. **最后清理**:全部重写完成后再删除旧代码和重命名 package
---
---
## 九、2026-02-16 审计发现 + 2026-02-17 进展记录
### 9.1 总体完成度
| 指标 | 数值 |
|------|------|
| 阶段完成数 | 7/8 完成 (1, 2, 4, 5, 6, 7, 8),1/8 部分完成 (3) |
| GORM 链式调用 | ~226 处 |
| Raw SQL 调用 (`.Exec`/`.Raw`+`.Scan`) | 4 处(生产代码) |
| GORM 占比 | ~98% |
| Handler 内 `tx.Exec`/`tx.Raw` | 0 处(生产代码) |
| `last_insert_rowid()` 生产代码 | 0 处(已消灭) |
### 9.2 ✅ P0:`last_insert_rowid()`(生产代码)已清零
`last_insert_rowid()` 已从生产路径移除,创建主键统一改为 `Create(&model)` 自动回填 ID,
确保 SQLite / PostgreSQL 双数据库行为一致。
> 备注:测试代码中的历史 SQL 兼容性用例可在后续测试清理阶段单独处理。
### 9.3 ✅ P1:`repository_mutations.go` 已全量 GORM 化
本次已完成 `repository_mutations.go` 的集中清理:
1. User / Node / Tunnel / Forward / UserTunnel / SpeedLimit / Group / Permission 全部 mutation 方法改为 GORM 链式调用。
2. 事务内级联删除统一为 `tx.Where(...).Delete(&Model{})` 模式。
3. `ON CONFLICT DO NOTHING` 统一替换为 `Clauses(clause.OnConflict{DoNothing: true})`。
4. 保留原有调用语义(含 `sql.ErrNoRows` 行为兼容)并完成 `go build ./...` 验证。
> 当前 `repository_mutations.go` 中生产代码 `.Raw(`/`.Exec(` 调用已降为 0。
### 9.4 ✅ P2:Handler `mutations.go` 直接 SQL 已清零
2026-02-17 本轮静态扫描结果:`mutations.go` **0 处** `tx.Exec`/`tx.Raw`(生产代码)。
本轮完成下沉到 repo 的逻辑:
- `tunnelUpdate` 中 `UPDATE tunnel` + `DELETE chain_tunnel`
- `isRemoteNodeTx` 查询
- `pickNodePortTx` 的 node/chain_tunnel/forward_port 端口占用查询
- `replaceTunnelChainsTx` 的 chain_tunnel 写入
- 分组成员同步(`tunnel_group_tunnel` / `user_group_user`)
- 权限删除与 grant 回收(`group_permission` / `group_permission_grant` / `user_tunnel`)
- federation 绑定替换(`federation_tunnel_binding`)
### 9.5 ✅ P3(部分):已移除 `QueryInt64List` / `QueryPairs` SQL 透传
- `repository_mutations.go` 中两个 SQL 透传入口已删除。
- Handler 已切换为语义化 repo 方法:
- `ListUserIDsByUserGroup`
- `ListTunnelIDsByTunnelGroup`
- `ListGroupPermissionPairsByUserGroup`
- `ListGroupPermissionPairsByTunnelGroup`
### 9.6 ✅ P3:Import 函数已全部 GORM 化
`repository.go` 中 Import 相关函数已完成迁移:
- `importUsers`
- `importNodes`
- `importTunnels`(含 `chain_tunnel` 子项 upsert)
- `importForwards`(含 `forward_port` 覆盖写入)
- `importUserTunnels`
- `importSpeedLimits`
- `importTunnelGroups`
- `importUserGroups`
- `importPermissions`
- `importConfigs`(原本已是 GORM)
迁移后统一采用 `Clauses(clause.OnConflict{Columns: id/name, DoUpdates: ...}).Create(&model)` 模式,
保留原 `ON CONFLICT ... DO UPDATE` 语义;Import 区段 `tx.Exec`/`tx.Raw` 已清零。
### 9.7 ✅ P4:`h.repo.DB() == nil` 检查已清理
`internal/http/handler/` 下已无 `h.repo.DB()` 直接访问;handler 仅通过语义化 repo 方法进行数据访问。
### 9.8 ✅ P5:Repository 层 Raw 已收敛(仅保留 PG 序列修复 DDL)
当前生产代码中 `.Raw()` 已清零;仅剩 `repository.go` 的 4 处 `Exec()`,全部位于 PG 序列修复 DDL:
- `CREATE SEQUENCE IF NOT EXISTS ...`
- `ALTER TABLE ... ALTER COLUMN id SET DEFAULT nextval(...)`
- `ALTER SEQUENCE ... OWNED BY ...`
- `SELECT setval(...::regclass, ?, ?)`
以上 4 处属于数据库管理 DDL/序列同步语义,当前保留,不再继续向 GORM 链式调用替换。
`repository_federation.go` / `repository_control.go` / `repository_flow.go` 已完成 GORM 化(Raw=0)。
---
## 十、后续工作优先级
| 优先级 | 任务 | 影响范围 | 工作量 |
|--------|------|----------|--------|
| **P0** | ✅ 已完成:生产代码中 `last_insert_rowid()` 清零(测试用例待单独清理) | 6 处生产(已完成) | 完成 |
| **P1** | ✅ 已完成:`repository_mutations.go` ~40 方法改为 GORM 链式调用 | 659 行(已完成) | 完成 |
| **P2** | ✅ 已完成:`mutations.go` handler 直接 SQL 全部提取为 repo 方法 | mutations.go | 完成 |
| **P3** | ✅ 已完成:移除 `QueryInt64List`/`QueryPairs` 透传,切换语义化 repo 方法 | 2 个方法 + 调用方(已完成) | 完成 |
| **P3** | ✅ 已完成:Import 函数 Raw SQL 改为 GORM `Clauses(clause.OnConflict{}).Create()` | 9 个函数(已完成) | 完成 |
| **P4** | ✅ 已完成:`h.repo.DB() == nil` 检查清理完毕 | 4 处(已完成) | 完成 |
| **P5** | ✅ 已完成:repo 查询层 Raw 清零,`repository.go` 保留 4 处 PG 序列修复 DDL `Exec`(设计保留) | repository.go | 完成 |
| **P5** | ✅ 已完成:更新 MIGRATION_PLAN.md 状态标记与收尾记录 | 本文件 | 完成 |
### 10.5 本轮执行记录(2026-02-17,P5 schema 收尾)
1. 完成 `repository.go` schema 迁移段去 Raw:
- `normalizeStrategy` 改为 `Model(...).Where(...).Update(...)`
- `ensurePostgresIDDefaults`/`ensurePostgresTableIDDefault` 的 information_schema 查询改为 GORM `Table+Joins+Where+Scan`
- `syncPostgresTableIDSequence` 的 `MAX(id)` 查询改为 GORM `Table+Select+Scan`
2. 复扫结果:
- `repository.go` `.Raw()` = 0
- repo 生产路径剩余 `.Exec()` = 4(全部为 PG 序列修复 DDL)
3. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
### 10.6 本轮执行记录(2026-02-17,测试/构建收尾)
1. 修复事务内 SQLite 连接阻塞(`MaxOpenConns(1)` 场景):
- 新增 `GetNodeRecordTx` 并在 `prepareTunnelCreateState` 使用事务句柄读取节点。
- 新增 `GetNodeRemoteFieldsTx` 并在 `tunnelCreate` 事务内改用事务句柄读取远端字段。
- `applyFederationRuntime` 改为显式接收 `localDomain`,避免事务内再次走 `repo.GetConfigByName`。
2. 修复 legacy SQLite schema 迁移契约:
- 新增 `prepareSQLiteLegacyColumns` 预补齐 `node/tunnel` 关键列。
- SQLite 模式下对已存在 `node/tunnel` 表跳过对应 `AutoMigrate` 重建流程,避免 `node__temp.name` 约束失败。
3. 验证结果:
- `go test ./internal/http/handler/...` ✅
- `go test ./tests/contract/...` ✅
- `go test ./...` ✅
- `go build ./...` ✅
- `make build` ✅
### 10.1 本轮执行记录(2026-02-17,P5 查询层)
1. 完成 `repository_federation.go` 全量 GORM 化:
- `ListRemoteNodes` / `UpdateNodeRemoteConfig`
- `ListActiveBindingsForNode` / `GetNodeBasicInfo`
- `ListUsedPortsOnNode` / `ListTunnelIDsByNamePrefix` / `NextIndex`
2. 完成 `repository_control.go` 全量 GORM 化:
- `ListForwardsByTunnel` / `ListForwardPorts` / `GetTunnelOutProtocol`
- `ResolveUserTunnelAndLimiter` / `ListChainNodesForTunnel`
3. 完成 `repository_flow.go` 全量 GORM 化:
- `ListActiveForwardsByUser` / `ListActiveForwardsByUserTunnel`
- `GetForwardRecord` / `GetTunnelRecord`
4. 复扫结果:
- `repository_federation.go` Raw/Exec = 0
- `repository_control.go` Raw/Exec = 0
- `repository_flow.go` Raw/Exec = 0
- repo 生产路径剩余 Raw/Exec = 9(全部在 `repository.go`)
5. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
### 10.2 本轮执行记录(2026-02-17)
1. 完成 P3 Import 9 个函数的 GORM 化(`repository.go`),并保持 `ON CONFLICT` 语义一致。
2. 复扫确认:`repository.go` Import 区段 `tx.Exec`/`tx.Raw` 已清零。
3. 验证结果:
- `go build ./...` ✅(使用显式 `GOMODCACHE/GOPATH/GOCACHE/HOME` 环境)
- `go test ./internal/store/repo/...` ✅
### 10.3 本轮执行记录(2026-02-17,P2 部分)
1. 将 tunnel 更新/chain 重建路径 SQL 下沉到 `repository_mutations.go`:
- 新增 `UpdateTunnelTx`
- 新增 `DeleteChainTunnelsByTunnelTx`
- 新增 `CreateChainTunnelTx`
2. 将 handler 内部 SQL helper 迁移到 repo:
- 新增 `IsRemoteNodeTx`
- 新增 `PickNodePortTx`
- `replaceTunnelChainsTx` 改为 handler 方法并改用 repo 调用,不再直接 SQL
3. 复扫结果:`mutations.go` 直接 SQL 从 27 处降至 17 处。
4. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
### 10.4 本轮执行记录(2026-02-17,P2 收尾)
1. 新增并落地事务语义化 repo 方法:
- `ReplaceTunnelGroupMembersTx` / `ReplaceUserGroupMembersTx`
- `ListUserIDsByUserGroupTx`
- `GetGroupPermissionPairByIDTx` / `DeleteGroupPermissionByIDTx`
- `RevokeGroupGrantsForRemovedUsersTx` / `RevokeGroupPermissionPairTx`
- `ReplaceFederationTunnelBindingsTx`
2. 删除 handler 内 SQL helper(`queryInt64ListTx` / `revokeGroupGrantsForRemovedUsersTx` / `revokeGroupPermissionPairTx` / `replaceFederationTunnelBindingsTx`)。
3. 复扫确认:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` = 0。
4. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
---
*本文档将随迁移进展实时更新状态标记。*
*最后审计时间:2026-02-17,审计工具:代码静态分析 (grep/AST) + go build/go test 验证*
-12
View File
@@ -1,12 +0,0 @@
GO ?= go
.PHONY: test build run
test:
$(GO) test ./...
build:
$(GO) build ./cmd/paneld
run:
SERVER_ADDR=:6365 $(GO) run ./cmd/paneld
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go-backend/internal/app"
"go-backend/internal/config"
)
func main() {
cfg := config.FromEnv()
if cfg.JWTSecret == "" {
log.Println("warning: JWT_SECRET is empty")
}
log.Printf("starting go-backend on %s (db=%s)", cfg.Addr, cfg.DBPath)
a, err := app.New(cfg)
if err != nil {
log.Fatalf("failed to create app: %v", err)
}
errCh := make(chan error, 1)
go func() {
errCh <- a.Run()
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
log.Printf("received signal %s, shutting down", sig)
case runErr := <-errCh:
if runErr != nil && !errors.Is(runErr, http.ErrServerClosed) {
log.Fatalf("server stopped unexpectedly: %v", runErr)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.Shutdown(ctx); err != nil {
log.Fatalf("shutdown failed: %v", err)
}
}
Binary file not shown.
-34
View File
@@ -1,34 +0,0 @@
module go-backend
go 1.25.0
require (
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.3
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
)
-87
View File
@@ -1,87 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-76
View File
@@ -1,76 +0,0 @@
package app
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"go-backend/internal/config"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/repo"
)
type App struct {
cfg config.Config
server *http.Server
repo *repo.Repository
h *handler.Handler
}
func New(cfg config.Config) (*App, error) {
var (
r *repo.Repository
err error
)
switch strings.ToLower(strings.TrimSpace(cfg.DBType)) {
case "", "sqlite":
r, err = repo.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
case "postgres", "postgresql":
r, err = repo.OpenPostgres(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
default:
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
}
h := handler.New(r, cfg.JWTSecret)
router := httpserver.NewRouter(h, cfg.JWTSecret)
s := &http.Server{
Addr: cfg.Addr,
Handler: router,
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 2 * time.Minute,
IdleTimeout: 60 * time.Second,
}
return &App{cfg: cfg, server: s, repo: r, h: h}, nil
}
func (a *App) Run() error {
if a.h != nil {
a.h.StartBackgroundJobs()
}
return a.server.ListenAndServe()
}
func (a *App) Shutdown(ctx context.Context) error {
if a.h != nil {
a.h.StopBackgroundJobs()
}
shutdownErr := a.server.Shutdown(ctx)
closeErr := a.repo.Close()
if shutdownErr != nil {
return shutdownErr
}
return closeErr
}
-121
View File
@@ -1,121 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strconv"
"time"
)
const (
algorithm = "HmacSHA256"
expireTime = 90 * 24 * time.Hour
)
type Claims struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
User string `json:"user"`
Name string `json:"name"`
RoleID int `json:"role_id"`
}
type tokenHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
now := time.Now()
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
claims := Claims{
Sub: strconv.FormatInt(userID, 10),
Iat: now.Unix(),
Exp: now.Add(expireTime).Unix(),
User: username,
Name: username,
RoleID: roleID,
}
headerPart, err := encodeJSON(header)
if err != nil {
return "", err
}
payloadPart, err := encodeJSON(claims)
if err != nil {
return "", err
}
sig := sign(headerPart+"."+payloadPart, secret)
return headerPart + "." + payloadPart + "." + sig, nil
}
func ValidateToken(token, secret string) (Claims, bool) {
claims, err := ParseClaims(token, secret)
if err != nil {
return Claims{}, false
}
return claims, true
}
func ParseClaims(token, secret string) (Claims, error) {
parts := splitToken(token)
if len(parts) != 3 {
return Claims{}, errors.New("invalid token")
}
signedContent := parts[0] + "." + parts[1]
expected := sign(signedContent, secret)
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
return Claims{}, errors.New("invalid signature")
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, err
}
var claims Claims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return Claims{}, err
}
if claims.Exp <= time.Now().Unix() {
return Claims{}, errors.New("token expired")
}
return claims, nil
}
func splitToken(token string) []string {
parts := make([]string, 0, 3)
current := ""
for i := 0; i < len(token); i++ {
if token[i] == '.' {
parts = append(parts, current)
current = ""
continue
}
current += string(token[i])
}
parts = append(parts, current)
return parts
}
func encodeJSON(v interface{}) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func sign(content, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(content))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
-32
View File
@@ -1,32 +0,0 @@
package config
import "os"
type Config struct {
Addr string
DBType string
DBPath string
DatabaseURL string
JWTSecret string
LogDir string
}
func FromEnv() Config {
cfg := Config{
Addr: getEnv("SERVER_ADDR", ":6365"),
DBType: getEnv("DB_TYPE", "sqlite"),
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
LogDir: getEnv("LOG_DIR", "/app/logs"),
}
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
-402
View File
@@ -1,402 +0,0 @@
package health
import (
"context"
"errors"
"fmt"
"log"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type nodeCommander interface {
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
}
const serviceMonitorReportInterval = 30 * time.Second // DB write interval per monitor
type Checker struct {
repo *repo.Repository
commander nodeCommander
lastRun map[int64]int64
inFlight map[int64]struct{}
// In-memory latest result per monitor (for real-time API reads)
latestResults map[int64]*model.ServiceMonitorResult
lastDBWrite map[int64]int64 // last DB write timestamp per monitorID
mu sync.RWMutex
cancel context.CancelFunc
wg sync.WaitGroup
checking int32 // atomic flag: 1 = runChecks running, 0 = idle
}
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
return &Checker{
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
latestResults: make(map[int64]*model.ServiceMonitorResult),
lastDBWrite: make(map[int64]int64),
}
}
// GetLatestCached returns the in-memory latest results (updated every 1s).
// Returns nil if no results are cached.
func (c *Checker) GetLatestCached() []*model.ServiceMonitorResult {
c.mu.RLock()
defer c.mu.RUnlock()
results := make([]*model.ServiceMonitorResult, 0, len(c.latestResults))
for _, r := range c.latestResults {
results = append(results, r)
}
return results
}
func (c *Checker) Start(ctx context.Context) {
c.mu.Lock()
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.mu.Unlock()
c.runChecks(ctx)
for {
limits := c.loadServiceMonitorLimits()
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
if scanInterval <= 0 {
scanInterval = 1 * time.Second
}
timer := time.NewTimer(scanInterval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
c.runChecks(ctx)
}
}
}
func (c *Checker) Stop() {
c.mu.Lock()
if c.cancel != nil {
c.cancel()
}
c.mu.Unlock()
c.wg.Wait()
}
func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult, error) {
if c == nil {
return nil, errors.New("checker not initialized")
}
if m == nil {
return nil, errors.New("monitor is nil")
}
limits := c.loadServiceMonitorLimits()
return c.executeCheck(m, time.Now().UnixMilli(), limits), nil
}
func (c *Checker) runChecks(ctx context.Context) {
// Skip if previous round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&c.checking, 0, 1) {
return
}
defer atomic.StoreInt32(&c.checking, 0)
if c == nil || c.repo == nil {
return
}
limits := c.loadServiceMonitorLimits()
monitors, err := c.repo.ListEnabledServiceMonitors()
if err != nil {
log.Printf("service monitor scheduler failed op=list_enabled err=%v", err)
return
}
if len(monitors) == 0 {
return
}
// Use persisted result timestamps to avoid restart bursts.
latest, err := c.repo.GetLatestServiceMonitorResults()
if err != nil {
log.Printf("service monitor scheduler failed op=get_latest_results err=%v", err)
latest = nil
}
persistedLast := make(map[int64]int64, len(latest))
for _, r := range latest {
if r.MonitorID <= 0 || r.Timestamp <= 0 {
continue
}
persistedLast[r.MonitorID] = r.Timestamp
}
now := time.Now().UnixMilli()
due := make([]model.ServiceMonitor, 0, len(monitors))
for _, m := range monitors {
select {
case <-ctx.Done():
return
default:
}
intervalSec := m.IntervalSec
if intervalSec <= 0 {
intervalSec = limits.DefaultIntervalSec
}
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
intervalMs := int64(intervalSec) * 1000
c.mu.Lock()
if _, ok := c.inFlight[m.ID]; ok {
c.mu.Unlock()
continue
}
lastSeen := persistedLast[m.ID]
if v := c.lastRun[m.ID]; v > lastSeen {
lastSeen = v
}
if lastSeen > 0 && intervalMs > 0 && now-lastSeen < intervalMs {
c.mu.Unlock()
continue
}
c.inFlight[m.ID] = struct{}{}
// Use now as a best-effort guard against overlapping scans; the final
// timestamp is updated again when the result is persisted.
c.lastRun[m.ID] = now
c.mu.Unlock()
due = append(due, m)
}
if len(due) == 0 {
return
}
workerLimit := limits.WorkerLimit
if workerLimit <= 0 {
workerLimit = 1
}
if workerLimit > len(due) {
workerLimit = len(due)
}
jobs := make(chan model.ServiceMonitor, len(due))
for _, m := range due {
jobs <- m
}
close(jobs)
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
for i := 0; i < workerLimit; i++ {
c.wg.Add(1)
go func() {
defer c.wg.Done()
for {
select {
case <-ctx.Done():
return
case m, ok := <-jobs:
if !ok {
return
}
ts := time.Now().UnixMilli()
result := c.executeCheck(&m, ts, limits)
// Always update in-memory cache for real-time reads
c.mu.Lock()
c.latestResults[m.ID] = result
c.lastRun[m.ID] = result.Timestamp
delete(c.inFlight, m.ID)
// Only write to DB every 30s per monitor
lastWrite := c.lastDBWrite[m.ID]
writeToDB := ts-lastWrite >= reportIntervalMs
if writeToDB {
c.lastDBWrite[m.ID] = ts
}
c.mu.Unlock()
if writeToDB {
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
}
}
}
}
}()
}
}
func (c *Checker) executeCheck(m *model.ServiceMonitor, timestamp int64, limits monitoring.ServiceMonitorLimits) *model.ServiceMonitorResult {
result := &model.ServiceMonitorResult{
MonitorID: m.ID,
NodeID: m.NodeID,
Timestamp: timestamp,
}
timeoutSec := m.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = limits.DefaultTimeoutSec
}
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
timeout := time.Duration(timeoutSec) * time.Second
// When nodeId is set, run checks on the specified node.
if m.NodeID > 0 {
c.checkOnNode(m, timeoutSec, timeout, result)
return result
}
switch strings.ToLower(strings.TrimSpace(m.Type)) {
case "tcp":
c.checkTCP(m.Target, timeout, result)
case "icmp":
result.Success = 0
result.ErrorMessage = "ICMP 监控必须指定执行节点"
default:
result.Success = 0
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
}
return result
}
func (c *Checker) loadServiceMonitorLimits() monitoring.ServiceMonitorLimits {
defaults := monitoring.DefaultServiceMonitorLimits()
if c == nil || c.repo == nil {
return defaults
}
cfg, err := c.repo.GetConfigsByNames([]string{
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
monitoring.ConfigServiceMonitorWorkerLimit,
monitoring.ConfigServiceMonitorMinIntervalSec,
monitoring.ConfigServiceMonitorDefaultIntervalSec,
monitoring.ConfigServiceMonitorMinTimeoutSec,
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
monitoring.ConfigServiceMonitorMaxTimeoutSec,
})
if err != nil {
return defaults
}
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
}
type serviceMonitorCheckRequest struct {
MonitorID int64 `json:"monitorId"`
Type string `json:"type"`
Target string `json:"target"`
TimeoutSec int `json:"timeoutSec"`
}
func (c *Checker) checkOnNode(m *model.ServiceMonitor, timeoutSec int, timeout time.Duration, result *model.ServiceMonitorResult) {
if c == nil || m == nil || result == nil {
return
}
if c.commander == nil {
result.Success = 0
result.ErrorMessage = "节点检查不可用"
return
}
checkType := strings.ToLower(strings.TrimSpace(m.Type))
if checkType != "tcp" && checkType != "icmp" {
result.Success = 0
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
return
}
if strings.TrimSpace(m.Target) == "" {
result.Success = 0
result.ErrorMessage = "检查目标为空"
return
}
req := serviceMonitorCheckRequest{
MonitorID: m.ID,
Type: checkType,
Target: m.Target,
TimeoutSec: timeoutSec,
}
cmdTimeout := timeout
if cmdTimeout < 2*time.Second {
cmdTimeout = 2 * time.Second
}
cmdTimeout = cmdTimeout + 2*time.Second
cmdRes, err := c.commander.SendCommand(m.NodeID, "ServiceMonitorCheck", req, cmdTimeout)
if err != nil {
result.Success = 0
result.ErrorMessage = err.Error()
return
}
if cmdRes.Data == nil {
result.Success = 0
result.ErrorMessage = "节点返回为空"
return
}
if v, ok := cmdRes.Data["success"]; ok {
if b, ok := v.(bool); ok {
if b {
result.Success = 1
} else {
result.Success = 0
}
}
}
if v, ok := cmdRes.Data["latencyMs"]; ok {
if f, ok := v.(float64); ok {
result.LatencyMs = f
}
}
if v, ok := cmdRes.Data["statusCode"]; ok {
if f, ok := v.(float64); ok {
result.StatusCode = int(f)
}
}
if v, ok := cmdRes.Data["errorMessage"]; ok {
if s, ok := v.(string); ok {
result.ErrorMessage = s
}
}
}
func (c *Checker) checkTCP(target string, timeout time.Duration, result *model.ServiceMonitorResult) {
start := time.Now()
conn, err := net.DialTimeout("tcp", target, timeout)
latency := time.Since(start)
result.LatencyMs = float64(latency.Milliseconds())
if err != nil {
result.Success = 0
result.ErrorMessage = err.Error()
return
}
_ = conn.Close()
result.Success = 1
}
-477
View File
@@ -1,477 +0,0 @@
package health
import (
"context"
"net"
"testing"
"time"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type fakeCommander struct {
lastNodeID int64
lastType string
lastData interface{}
res ws.CommandResult
err error
}
type delayedCommander struct {
delayByMonitorID map[int64]time.Duration
}
func (d *delayedCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
_ = nodeID
_ = cmdType
if req, ok := data.(serviceMonitorCheckRequest); ok {
if delay := d.delayByMonitorID[req.MonitorID]; delay > 0 {
time.Sleep(delay)
}
}
return ws.CommandResult{
Success: true,
Data: map[string]interface{}{
"success": true,
"latencyMs": float64(1),
},
}, nil
}
func (f *fakeCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
f.lastNodeID = nodeID
f.lastType = cmdType
f.lastData = data
return f.res, f.err
}
func TestTCPHealthCheckViaMonitor(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
addr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
t.Run("successful tcp check", func(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: addr,
TimeoutSec: 5,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success != 1 {
t.Fatalf("expected success, got error: %s", result.ErrorMessage)
}
if result.LatencyMs < 0 {
t.Fatalf("expected non-negative latency, got %f", result.LatencyMs)
}
})
t.Run("failed tcp check - connection refused", func(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: "127.0.0.1:1",
TimeoutSec: 1,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success == 1 {
t.Fatalf("expected failure for connection refused")
}
if result.ErrorMessage == "" {
t.Fatalf("expected error message")
}
})
}
func TestCheckerRunChecks(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
tcpAddr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
now := time.Now().UnixMilli()
monitors := []*model.ServiceMonitor{
{
Name: "TCP Monitor",
Type: "tcp",
Target: tcpAddr,
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
},
{
Name: "TCP Monitor 2",
Type: "tcp",
Target: tcpAddr,
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
},
{
Name: "Disabled Monitor",
Type: "tcp",
Target: "127.0.0.1:1",
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 0,
CreatedTime: now,
UpdatedTime: now,
},
}
for _, m := range monitors {
if err := r.CreateServiceMonitor(m); err != nil {
t.Fatalf("create monitor: %v", err)
}
}
monitors[2].Enabled = 0
if err := r.UpdateServiceMonitor(monitors[2]); err != nil {
t.Fatalf("update disabled monitor: %v", err)
}
enabledMonitors, err := r.ListEnabledServiceMonitors()
if err != nil {
t.Fatalf("list enabled monitors: %v", err)
}
if len(enabledMonitors) != 2 {
t.Fatalf("expected 2 enabled monitors, got %d", len(enabledMonitors))
}
checker := NewChecker(r, nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go checker.Start(ctx)
time.Sleep(500 * time.Millisecond)
results, err := r.GetServiceMonitorResults(monitors[0].ID, 10)
if err != nil {
t.Fatalf("get tcp results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected at least one result for tcp monitor")
}
for _, res := range results {
if res.Success != 1 {
t.Fatalf("expected success for tcp monitor, got failure: %s", res.ErrorMessage)
}
}
results2, err := r.GetServiceMonitorResults(monitors[1].ID, 10)
if err != nil {
t.Fatalf("get tcp results 2: %v", err)
}
if len(results2) == 0 {
t.Fatalf("expected at least one result for tcp monitor 2")
}
for _, res := range results2 {
if res.Success != 1 {
t.Fatalf("expected success for tcp monitor 2, got failure: %s", res.ErrorMessage)
}
}
disabledResults, err := r.GetServiceMonitorResults(monitors[2].ID, 10)
if err != nil {
t.Fatalf("get disabled results: %v", err)
}
if len(disabledResults) != 0 {
t.Fatalf("expected no results for disabled monitor, got %d", len(disabledResults))
}
}
func TestCheckerUnsupportedType(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "http",
Target: "https://example.com",
TimeoutSec: 5,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success == 1 {
t.Fatalf("expected failure for unsupported type")
}
if result.ErrorMessage == "" {
t.Fatalf("expected error message for unsupported type")
}
}
func TestCheckerDefaultTimeout(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
addr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: addr,
TimeoutSec: 0,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success != 1 {
t.Fatalf("expected success with default timeout, got error: %s", result.ErrorMessage)
}
}
func TestCheckerStop(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Name: "Test Monitor",
Type: "tcp",
Target: listener.Addr().String(),
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(monitor); err != nil {
t.Fatalf("create monitor: %v", err)
}
checker := NewChecker(r, nil)
ctx := context.Background()
go checker.Start(ctx)
time.Sleep(100 * time.Millisecond)
checker.Stop()
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
if err != nil {
t.Fatalf("get results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected at least one result before stop")
}
}
func TestCheckerRunsOnNodeWhenNodeIDSet(t *testing.T) {
fake := &fakeCommander{
res: ws.CommandResult{
Success: true,
Data: map[string]interface{}{
"success": false,
"latencyMs": float64(12),
"errorMessage": "unreachable",
},
},
}
checker := NewChecker(nil, fake)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
ID: 99,
Type: "icmp",
Target: "8.8.8.8",
TimeoutSec: 2,
NodeID: 123,
}
res := checker.executeCheck(monitor, now, limits)
if fake.lastNodeID != 123 {
t.Fatalf("expected command to be sent to node 123, got %d", fake.lastNodeID)
}
if fake.lastType != "ServiceMonitorCheck" {
t.Fatalf("expected ServiceMonitorCheck command, got %s", fake.lastType)
}
if res.Success != 0 {
t.Fatalf("expected failed result from node check")
}
if res.ErrorMessage != "unreachable" {
t.Fatalf("expected errorMessage unreachable, got %q", res.ErrorMessage)
}
}
func TestCheckerDoesNotBurstOnRestartWhenRecentResultsExist(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Name: "recent-monitor",
Type: "tcp",
Target: "127.0.0.1:1",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(monitor); err != nil {
t.Fatalf("create monitor: %v", err)
}
if err := r.InsertServiceMonitorResult(&model.ServiceMonitorResult{
MonitorID: monitor.ID,
NodeID: 0,
Timestamp: now - 10_000,
Success: 1,
}); err != nil {
t.Fatalf("seed recent result: %v", err)
}
checker := NewChecker(r, nil)
ctx, cancel := context.WithCancel(context.Background())
go checker.Start(ctx)
// Give the initial scan a chance to run.
time.Sleep(200 * time.Millisecond)
cancel()
checker.Stop()
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
if err != nil {
t.Fatalf("get results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected no immediate rerun (1 result), got %d", len(results))
}
}
func TestCheckerConcurrencyPreventsSlowMonitorBlockingOthers(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
// Force worker limit to at least 2 for this test.
_ = r.UpsertConfig(monitoring.ConfigServiceMonitorWorkerLimit, "2", now)
slow := &model.ServiceMonitor{
Name: "slow",
Type: "icmp",
Target: "8.8.8.8",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 123,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(slow); err != nil {
t.Fatalf("create slow monitor: %v", err)
}
fast := &model.ServiceMonitor{
Name: "fast",
Type: "icmp",
Target: "1.1.1.1",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 123,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(fast); err != nil {
t.Fatalf("create fast monitor: %v", err)
}
cmd := &delayedCommander{delayByMonitorID: map[int64]time.Duration{slow.ID: 800 * time.Millisecond}}
checker := NewChecker(r, cmd)
ctx, cancel := context.WithCancel(context.Background())
go checker.Start(ctx)
// Fast monitor should complete even while slow one is still running.
time.Sleep(250 * time.Millisecond)
results, err := r.GetServiceMonitorResults(fast.ID, 10)
if err != nil {
t.Fatalf("get fast results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected fast monitor to have results without waiting for slow")
}
cancel()
checker.Stop()
}
@@ -1,387 +0,0 @@
package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type FederationClient struct {
client *http.Client
}
type RemoteNodeInfo struct {
ShareID int64 `json:"shareId"`
ShareName string `json:"shareName"`
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
}
type RemoteTunnelResponse struct {
TunnelID int64 `json:"tunnelId"`
}
type RuntimeReservePortRequest struct {
ResourceKey string `json:"resourceKey"`
Protocol string `json:"protocol"`
RequestedPort int `json:"requestedPort"`
}
type RuntimeReservePortResponse struct {
ReservationID string `json:"reservationId"`
BindingID string `json:"bindingId"`
AllocatedPort int `json:"allocatedPort"`
}
type RuntimeTarget struct {
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
}
type RuntimeApplyRoleRequest struct {
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []RuntimeTarget `json:"targets"`
}
type RuntimeApplyRoleResponse struct {
BindingID string `json:"bindingId"`
ReservationID string `json:"reservationId"`
AllocatedPort int `json:"allocatedPort"`
}
type RuntimeReleaseRoleRequest struct {
BindingID string `json:"bindingId"`
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
}
type RuntimeDiagnoseRequest struct {
IP string `json:"ip"`
Port int `json:"port"`
Count int `json:"count"`
Timeout int `json:"timeout"`
Protocol string `json:"protocol"`
}
type RuntimeNodeCommandRequest struct {
CommandType string `json:"commandType"`
Data interface{} `json:"data"`
}
type RuntimeNodeCommandResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}
func NewFederationClient() *FederationClient {
return &FederationClient{
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient {
return &FederationClient{
client: &http.Client{
Timeout: timeout,
},
}
}
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
url = strings.TrimSuffix(url, "/")
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RemoteNodeInfo `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
func (c *FederationClient) CreateTunnel(url, token, localDomain, protocol string, remotePort int, target string) (*RemoteTunnelResponse, error) {
url = strings.TrimSuffix(url, "/")
payload := map[string]interface{}{
"protocol": protocol,
"remotePort": remotePort,
"target": target,
}
bodyBytes, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url+"/api/v1/federation/tunnel/create", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RemoteTunnelResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
func (c *FederationClient) ReservePort(url, token, localDomain string, reqData RuntimeReservePortRequest) (*RuntimeReservePortResponse, error) {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/reserve-port", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RuntimeReservePortResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData RuntimeApplyRoleRequest) (*RuntimeApplyRoleResponse, error) {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/apply-role", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RuntimeApplyRoleResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
func (c *FederationClient) ReleaseRole(url, token, localDomain string, reqData RuntimeReleaseRoleRequest) error {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/release-role", strings.NewReader(string(bodyBytes)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return err
}
if res.Code != 0 {
return fmt.Errorf("remote api error: %s", res.Msg)
}
return nil
}
func (c *FederationClient) Diagnose(url, token, localDomain string, reqData RuntimeDiagnoseRequest) (map[string]interface{}, error) {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/diagnose", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
if res.Data == nil {
return nil, fmt.Errorf("remote api error: empty diagnosis payload")
}
return res.Data, nil
}
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RuntimeNodeCommandResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
@@ -1,49 +0,0 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
## STRUCTURE
```
handler/
├── handler.go # Main Handler struct, login/captcha, job scheduling
├── control_plane.go # Node control plane API (add/delete/list)
├── federation.go # Federation/cluster sync API
├── flow_policy.go # Traffic policy API
├── jobs.go # Background job management (sync, cleanup)
├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)
└── upgrade.go # System upgrade API
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |
| **Login/Captcha** | `handler.go` | Login flow, captcha verification |
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
| **Node Control** | `control_plane.go` | Node add/delete/list operations |
## CONVENTIONS
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
## ANTI-PATTERNS
- Do NOT let handlers call `repo.DB()` directly — add a Repository method instead.
- Do NOT change handler signatures without updating router.go.
## COMMANDS
```bash
cd go-backend
go test ./internal/http/handler/...
```
File diff suppressed because it is too large Load Diff
@@ -1,544 +0,0 @@
package handler
import (
"errors"
"reflect"
"testing"
"go-backend/internal/store/repo"
)
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
base := "12_34_56"
want := []string{base + "_tcp", base + "_udp"}
for _, command := range []string{"PauseService", "ResumeService"} {
got := buildForwardControlServiceNames(base, command)
if !reflect.DeepEqual(got, want) {
t.Fatalf("command %s expected %v, got %v", command, want, got)
}
}
}
func TestBuildForwardControlServiceNamesDelete(t *testing.T) {
base := "12_34_56"
want := []string{base, base + "_tcp", base + "_udp"}
got := buildForwardControlServiceNames(base, " DeleteService ")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestBuildForwardServiceBaseCandidates(t *testing.T) {
got := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})
want := []string{"12_34_56", "12_34_78", "12_34_90", "12_34_0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
got := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})
want := []string{"12_34_0", "12_34_78", "12_34_90"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
if got != "12_34_56" {
t.Fatalf("expected 12_34_56, got %s", got)
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
if got != "12_34_0" {
t.Fatalf("expected 12_34_0, got %s", got)
}
}
func TestShouldTryLegacySingleService(t *testing.T) {
if !shouldTryLegacySingleService("PauseService") {
t.Fatalf("PauseService should require legacy fallback")
}
if !shouldTryLegacySingleService("resumeService") {
t.Fatalf("ResumeService should require legacy fallback")
}
if shouldTryLegacySingleService("DeleteService") {
t.Fatalf("DeleteService should not require legacy fallback")
}
}
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
if !shouldSelfHealForwardServiceControl("PauseService") {
t.Fatalf("PauseService should trigger self-heal")
}
if !shouldSelfHealForwardServiceControl(" resumeService ") {
t.Fatalf("ResumeService should trigger self-heal")
}
if shouldSelfHealForwardServiceControl("DeleteService") {
t.Fatalf("DeleteService should not trigger self-heal")
}
}
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
bases := []string{"12_34_56"}
called := make([]string, 0)
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
called = append(called, name)
if name == "12_34_56_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatalf("expected handled=true")
}
if lastNotFoundErr != nil {
t.Fatalf("expected lastNotFoundErr=nil when handled")
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled {
t.Fatalf("expected handled=false")
}
if lastNotFoundErr == nil {
t.Fatalf("expected lastNotFoundErr when all variants are missing")
}
}
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "12_34_0" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
err := deleteForwardServiceCandidates(bases, func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("all-missing delete should be tolerated, got %v", err)
}
}
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
want := []string{"46_9_123", "46_9_77", "46_9_0"}
if !reflect.DeepEqual(bases, want) {
t.Fatalf("expected %v, got %v", want, bases)
}
}
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
bases := []string{"46_9_123", "46_9_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
bases := []string{"57_7_7", "57_7_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
switch name {
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
return nil
default:
return errors.New("service " + name + " not found")
}
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
h := &Handler{repo: nil}
node := &nodeRecord{ID: 9, Name: "test-node"}
_ = h
_ = node
rawRepo, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
h = &Handler{repo: rawRepo}
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
t.Fatalf("insert forward port: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
if err == nil {
t.Fatalf("expected occupancy error")
}
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
t.Fatalf("unexpected error: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
if err != nil {
t.Fatalf("same forward should be allowed, got %v", err)
}
}
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
if name == "12_34_56_tcp" {
return errors.New("network timeout")
}
return nil
})
if err == nil {
t.Fatalf("expected hard error")
}
if handled {
t.Fatalf("expected handled=false on hard error")
}
if lastNotFoundErr != nil {
t.Fatalf("did not expect not-found error alongside hard error")
}
}
func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("service demo already exists") {
t.Fatalf("expected already exists message to be tolerated")
}
if !isAlreadyExistsMessage("服务已存在") {
t.Fatalf("expected Chinese already exists message to be tolerated")
}
if !isAlreadyExistsMessage("service demo alreadyexists") {
t.Fatalf("missing-space alreadyexists should be tolerated")
}
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
t.Fatalf("address already in use must not be treated as already exists")
}
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
t.Fatalf("alreadyin-use variant must not be treated as already exists")
}
}
func TestIsBindAddressInUseError(t *testing.T) {
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if isBindAddressInUseError(errors.New("service demo already exists")) {
t.Fatalf("already exists should not be treated as bind conflict")
}
if isBindAddressInUseError(nil) {
t.Fatalf("nil error should not be treated as bind conflict")
}
}
func TestIsAddressAlreadyInUseError(t *testing.T) {
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
t.Fatalf("missing-space alreadyin-use variant should be detected")
}
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
}
}
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
}
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should not be treated as cannot-assign")
}
}
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
if addCalls == 1 {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
}
return nil
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err != nil {
t.Fatalf("expected retry to succeed, got %v", err)
}
if addCalls != 2 {
t.Fatalf("expected 2 add attempts, got %d", addCalls)
}
if cleanupCalls != 1 {
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
return errors.New("network timeout")
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err == nil {
t.Fatalf("expected hard error")
}
if addCalls != 1 {
t.Fatalf("expected 1 add attempt, got %d", addCalls)
}
if cleanupCalls != 0 {
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
cleanupErr := errors.New("delete failed")
err := retryTunnelServiceAddWithCleanup(
func() error {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
},
func() error {
return cleanupErr
},
0,
)
if !errors.Is(err, cleanupErr) {
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
}
}
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
for _, svc := range services {
addr, _ := svc["addr"].(string)
if addr != "10.9.8.7:22000" {
t.Fatalf("expected bind IP address 10.9.8.7:22000, got %q", addr)
}
}
}
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
tcpAddr, _ := services[0]["addr"].(string)
udpAddr, _ := services[1]["addr"].(string)
if tcpAddr != "0.0.0.0:22001" {
t.Fatalf("expected tcp addr 0.0.0.0:22001, got %q", tcpAddr)
}
if udpAddr != "[::]:22001" {
t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr)
}
}
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
for _, svc := range services {
addr, _ := svc["addr"].(string)
if addr != "3.3.3.3:12345" {
t.Fatalf("expected bind IP with port 3.3.3.3:12345, got %q", addr)
}
}
}
func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
tests := []struct {
name string
bindIP string
port int
wantAddr string
}{
{
name: "pure ipv6 without port",
bindIP: "2001:db8::1",
port: 22000,
wantAddr: "[2001:db8::1]:22000",
},
{
name: "bracketed ipv6 without port",
bindIP: "[2001:db8::2]",
port: 22001,
wantAddr: "[2001:db8::2]:22001",
},
{
name: "bracketed ipv6 with port",
bindIP: "[2001:db8::3]:8080",
port: 55555,
wantAddr: "[2001:db8::3]:8080",
},
{
name: "ipv6 link-local with zone",
bindIP: "fe80::1%eth0",
port: 22002,
wantAddr: "[fe80::1%eth0]:22002",
},
{
name: "ipv6 localhost",
bindIP: "::1",
port: 22003,
wantAddr: "[::1]:22003",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
for _, svc := range services {
addr, _ := svc["addr"].(string)
if addr != tt.wantAddr {
t.Fatalf("expected addr %q, got %q", tt.wantAddr, addr)
}
}
})
}
}
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "https with path",
in: "https://panel.example.com:8443/api/v1",
want: "panel.example.com:8443",
},
{
name: "wss with query",
in: "wss://panel.example.com:443/system-info?x=1",
want: "panel.example.com:443",
},
{
name: "http without port",
in: "http://panel.example.com",
want: "panel.example.com",
},
{
name: "manual host with trailing path",
in: "panel.example.com:8080/path",
want: "panel.example.com:8080",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "ipv6 host only",
in: "2001:db8::1",
want: "[2001:db8::1]",
},
{
name: "ipv6 host and port",
in: "https://[2001:db8::1]:8443/path",
want: "[2001:db8::1]:8443",
},
{
name: "already bracketed",
in: "[2001:db8::2]:9000",
want: "[2001:db8::2]:9000",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
@@ -1,50 +0,0 @@
package handler
import (
"testing"
"go-backend/internal/store/repo"
)
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
t.Helper()
var id int64
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
}
if id <= 0 {
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
}
return id
}
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
t.Helper()
var v int
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
t.Fatalf("query int failed: %v (query=%q)", err, query)
}
return v
}
func mustQueryInt64Int64String(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, string) {
t.Helper()
var a int64
var b int64
var c string
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
t.Fatalf("query int64+int64+string failed: %v (query=%q)", err, query)
}
return a, b, c
}
func mustQueryInt64Int64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, int) {
t.Helper()
var a int64
var b int64
var c int
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
t.Fatalf("query int64+int64+int failed: %v (query=%q)", err, query)
}
return a, b, c
}
@@ -1,209 +0,0 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"go-backend/internal/http/response"
)
type diagnosisStreamEvent struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
TS int64 `json:"ts"`
}
func prepareDiagnosisStreamResponse(w http.ResponseWriter) (http.Flusher, error) {
flusher, ok := w.(http.Flusher)
if !ok {
return nil, errors.New("当前服务不支持流式响应")
}
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
return flusher, nil
}
func writeDiagnosisStreamEvent(encoder *json.Encoder, flusher http.Flusher, eventType string, data interface{}) error {
if encoder == nil || flusher == nil {
return errors.New("流式响应写入器未初始化")
}
event := diagnosisStreamEvent{Type: eventType, Data: data, TS: time.Now().UnixMilli()}
if err := encoder.Encode(event); err != nil {
return err
}
flusher.Flush()
return nil
}
func summarizeDiagnosisProgress(results []map[string]interface{}) diagnosisProgress {
progress := diagnosisProgress{Total: len(results)}
for _, item := range results {
progress.Completed++
if asBool(item["success"], false) {
progress.Success++
} else {
progress.Failed++
}
}
return progress
}
func shouldIgnoreDiagnosisStreamError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return true
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset by peer") {
return true
}
if strings.Contains(msg, "stream already closed") {
return true
}
return false
}
func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.CancelFunc, w http.ResponseWriter, startPayload map[string]interface{}, workItems []diagnosisWorkItem) error {
flusher, err := prepareDiagnosisStreamResponse(w)
if err != nil {
return err
}
encoder := json.NewEncoder(w)
payload := map[string]interface{}{
"total": len(workItems),
"timestamp": time.Now().UnixMilli(),
"items": h.buildDiagnosisStreamStartItems(workItems),
}
for key, value := range startPayload {
payload[key] = value
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "start", payload); err != nil {
return err
}
streamBroken := false
emitter := func(index int, item map[string]interface{}, progress diagnosisProgress) {
if streamBroken {
return
}
itemPayload := map[string]interface{}{
"index": index,
"result": item,
"progress": progress,
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "item", itemPayload); err != nil {
streamBroken = true
if cancel != nil {
cancel()
}
}
}
results := h.runDiagnosisWorkItems(ctx, workItems, emitter)
if streamBroken {
return context.Canceled
}
progress := summarizeDiagnosisProgress(results)
donePayload := map[string]interface{}{
"progress": progress,
"timedOut": errors.Is(ctx.Err(), context.DeadlineExceeded),
}
return writeDiagnosisStreamEvent(encoder, flusher, "done", donePayload)
}
func (h *Handler) tunnelDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "tunnelId")
if id <= 0 {
return
}
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": tunnelType,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
func (h *Handler) forwardDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "forwardId")
if id <= 0 {
return
}
forward, _, _, err := h.resolveForwardAccess(r, id)
if err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"forwardName": forwardName,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
@@ -1,473 +0,0 @@
package handler
import (
"testing"
)
// ---------------------------------------------------------------------------
// nodeSupportsV4 / nodeSupportsV6
// ---------------------------------------------------------------------------
func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// Empty connectIp should be ignored, IP preference takes effect
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("empty connectIp should be ignored (v4 preference applies), got %q", host)
}
// Non-empty connectIp should override IP preference
host, err = selectTunnelDialHost(from, to, "v6", "192.168.0.3")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "192.168.0.3" {
t.Fatalf("connectIp should override v6 preference, got %q", host)
}
}
func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21000, ConnectIP: "2001:db8::88"}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "[2001:db8::88]:21000" {
t.Fatalf("expected connectIp listen [2001:db8::88]:21000, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "10.8.0.5:21002" {
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "[::]:21001" {
t.Fatalf("expected default listen [::]:21001, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_SetsRetriesWhenMultipleCandidates(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 3)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
handler, _ := services[0]["handler"].(map[string]interface{})
if handler == nil {
t.Fatal("expected handler config")
}
retries, ok := handler["retries"].(int)
if !ok {
t.Fatal("expected retries to be set when nextHopCandidateCount > 1")
}
if retries != 2 {
t.Fatalf("expected retries=2 (candidates-1), got %d", retries)
}
}
func TestBuildTunnelChainServiceConfig_NoRetriesWhenSingleCandidate(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
handler, _ := services[0]["handler"].(map[string]interface{})
if handler == nil {
t.Fatal("expected handler config")
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatal("expected no retries when nextHopCandidateCount is 1")
}
}
func TestNodeSupportsV6_Nil(t *testing.T) {
if nodeSupportsV6(nil) {
t.Fatal("nil node must not support v6")
}
}
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if !nodeSupportsV4(n) {
t.Fatal("explicit server_ip_v4 needs support v4")
}
}
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("explicit server_ip_v6 needs support v6")
}
}
func TestNodeSupportsV4_OnlyV6Set(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if nodeSupportsV4(n) {
t.Fatal("node with only v6 should not support v4")
}
}
func TestNodeSupportsV6_OnlyV4Set(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if nodeSupportsV6(n) {
t.Fatal("node with only v4 should not support v6")
}
}
func TestNodeSupportsV4_DualStack(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
if !nodeSupportsV4(n) {
t.Fatal("dual-stack node must support v4")
}
}
func TestNodeSupportsV6_DualStack(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("dual-stack node must support v6")
}
}
func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
n := &nodeRecord{ServerIP: "192.168.1.1"}
if !nodeSupportsV4(n) {
t.Fatal("legacy v4 ip in server_ip must support v4")
}
if nodeSupportsV6(n) {
t.Fatal("legacy v4 ip in server_ip should not support v6")
}
}
func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
n := &nodeRecord{ServerIP: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("legacy v6 ip in server_ip must support v6")
}
if nodeSupportsV4(n) {
t.Fatal("legacy v6 ip in server_ip should not support v4")
}
}
func TestNodeSupportsV4_EmptyNode(t *testing.T) {
n := &nodeRecord{}
if nodeSupportsV4(n) {
t.Fatal("empty node must not support v4")
}
if nodeSupportsV6(n) {
t.Fatal("empty node must not support v6")
}
}
func TestNodeSupportsV4_LegacyBracketed(t *testing.T) {
n := &nodeRecord{ServerIP: "[::1]"}
if nodeSupportsV4(n) {
t.Fatal("bracketed ipv6 must not support v4")
}
if !nodeSupportsV6(n) {
t.Fatal("bracketed ipv6 must support v6")
}
}
// ---------------------------------------------------------------------------
// pickNodeAddressV4 / pickNodeAddressV6
// ---------------------------------------------------------------------------
func TestPickNodeAddressV4_Nil(t *testing.T) {
if pickNodeAddressV4(nil) != "" {
t.Fatal("nil node must return empty")
}
}
func TestPickNodeAddressV6_Nil(t *testing.T) {
if pickNodeAddressV6(nil) != "" {
t.Fatal("nil node must return empty")
}
}
func TestPickNodeAddressV4_PreferExplicit(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIP: "192.168.0.1"}
got := pickNodeAddressV4(n)
if got != "10.0.0.1" {
t.Fatalf("expected explicit v4 10.0.0.1, got %q", got)
}
}
func TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {
n := &nodeRecord{ServerIP: "192.168.0.1"}
got := pickNodeAddressV4(n)
if got != "192.168.0.1" {
t.Fatalf("expected legacy 192.168.0.1, got %q", got)
}
}
func TestPickNodeAddressV6_PreferExplicit(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1", ServerIP: "::1"}
got := pickNodeAddressV6(n)
if got != "2001:db8::1" {
t.Fatalf("expected explicit v6 2001:db8::1, got %q", got)
}
}
func TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {
n := &nodeRecord{ServerIP: "::1"}
got := pickNodeAddressV6(n)
if got != "::1" {
t.Fatalf("expected legacy ::1, got %q", got)
}
}
// ---------------------------------------------------------------------------
// selectTunnelDialHost — core IP preference selection logic
// ---------------------------------------------------------------------------
func dualStackNode(name, v4, v6 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv4: v4,
ServerIPv6: v6,
}
}
func v4OnlyNode(name, v4 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv4: v4,
}
}
func v6OnlyNode(name, v6 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv6: v6,
}
}
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
_, err := selectTunnelDialHost(nil, nil, "", "")
if err == nil {
t.Fatal("expected error for nil nodes")
}
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "", "")
if err == nil {
t.Fatal("expected error for nil toNode")
}
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "", "")
if err == nil {
t.Fatal("expected error for nil fromNode")
}
}
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Default prefers v4 when both available
if host != "10.0.0.2" {
t.Fatalf("default preference should pick v4, got %q", host)
}
}
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("v4 preference should pick v4 address, got %q", host)
}
}
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("v6 preference should pick v6 address, got %q", host)
}
}
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v4OnlyNode("to", "10.0.0.2")
// User prefers v6, but both nodes are v4-only — should fallback to v4
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("v6 preference on v4-only nodes should fallback to v4, got %q", host)
}
}
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// User prefers v4, but both nodes are v6-only — should fallback to v6
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("v4 preference on v6-only nodes should fallback to v6, got %q", host)
}
}
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("expected v6 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("expected v4 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
// 真正不兼容:两个节点都没有任何 IP
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for nodes with no IP addresses")
}
}
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// Whitespace should be trimmed, treated as "v6"
host, err := selectTunnelDialHost(from, to, " v6 ", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("trimmed v6 preference should pick v6 address, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
// v6 preferred, but target only has v4 — should succeed with v4
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("should fallback to v4 when target is v4-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// v4 preferred, but target only has v6 — should succeed with v6
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("should fallback to v6 when target is v6-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("should use v4 when from is v4-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v4 preferred, but from only has v6 — should use v6
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("should use v6 when from is v6-only, got %q", host)
}
}
// ---------------------------------------------------------------------------
// nodeDisplayName
// ---------------------------------------------------------------------------
func TestNodeDisplayName_Nil(t *testing.T) {
got := nodeDisplayName(nil)
if got != "node" {
t.Fatalf("nil node display name should be 'node', got %q", got)
}
}
func TestNodeDisplayName_Named(t *testing.T) {
n := &nodeRecord{ID: 42, Name: "hk-node"}
got := nodeDisplayName(n)
if got != "hk-node" {
t.Fatalf("expected 'hk-node', got %q", got)
}
}
func TestNodeDisplayName_Unnamed(t *testing.T) {
n := &nodeRecord{ID: 42}
got := nodeDisplayName(n)
if got != "node_42" {
t.Fatalf("expected 'node_42', got %q", got)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,339 +0,0 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls").Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
share := &repo.PeerShare{
ID: 77,
NodeID: 1,
PortRangeStart: 3000,
PortRangeEnd: 3004,
}
port, err := h.pickPeerSharePort(share, 0)
if err != nil {
t.Fatalf("pick auto port: %v", err)
}
if port != 3003 {
t.Fatalf("expected port 3003, got %d", port)
}
if _, err := h.pickPeerSharePort(share, 3001); err == nil {
t.Fatalf("expected requested busy port to fail")
}
}
func TestApplyTunnelRuntimeSkipsRemoteChainAndOutNodes(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "rt-skip.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
for _, n := range []struct {
id int64
name string
ip string
}{
{12, "remote-chain", "10.99.0.2"},
{13, "remote-out", "10.99.0.3"},
} {
if err := r.DB().Exec(`
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.id, n.name, n.name+"-secret", n.ip, n.ip, "", "40000-40010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://remote-peer", "remote-token").Error; err != nil {
t.Fatalf("insert node %s: %v", n.name, err)
}
}
state := &tunnelCreateState{
TunnelID: 1,
Type: 2,
InNodes: []tunnelRuntimeNode{},
ChainHops: [][]tunnelRuntimeNode{
{
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
},
},
OutNodes: []tunnelRuntimeNode{
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
},
Nodes: map[int64]*nodeRecord{
12: {ID: 12, Name: "remote-chain", IsRemote: 1, ServerIPv4: "10.99.0.2"},
13: {ID: 13, Name: "remote-out", IsRemote: 1, ServerIPv4: "10.99.0.3"},
},
}
chains, services, err := h.applyTunnelRuntime(state)
if err != nil {
t.Fatalf("apply runtime: %v", err)
}
if len(chains) != 0 {
t.Fatalf("expected no local chains for remote-only nodes, got %d", len(chains))
}
if len(services) != 0 {
t.Fatalf("expected no local services for remote-only nodes, got %d", len(services))
}
}
func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
if execErr := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`).Error; execErr != nil {
t.Fatalf("insert node %s: %v", name, execErr)
}
return mustLastInsertID(t, r, name)
}
entryID := insertNode("entry", 1, "31000-31010", 0)
remoteOutID := insertNode("remote-out", 1, "30000", 1)
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
tx := r.DB().Begin()
if tx.Error != nil {
t.Fatalf("begin tx: %v", err)
}
defer tx.Rollback()
req := map[string]interface{}{
"name": "test-tunnel",
"inNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
},
"outNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(remoteOutID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
"chainNodes": []interface{}{},
}
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
if err != nil {
t.Fatalf("prepare state should not fail for remote auto-port: %v", err)
}
if len(state.OutNodes) != 1 {
t.Fatalf("expected 1 out node, got %d", len(state.OutNodes))
}
if state.OutNodes[0].Port != 0 {
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
}
}
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
if execErr := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`).Error; execErr != nil {
t.Fatalf("insert node %s: %v", name, execErr)
}
return mustLastInsertID(t, r, name)
}
entryID := insertNode("entry-local", 1, "32000-32010", 0)
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
outID := insertNode("out-local", 1, "34000-34010", 0)
tx := r.DB().Begin()
if tx.Error != nil {
t.Fatalf("begin tx: %v", err)
}
defer tx.Rollback()
req := map[string]interface{}{
"name": "remote-middle-offline-status",
"inNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{
[]interface{}{
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
},
"outNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
}
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
if err != nil {
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
}
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
}
if state.ChainHops[0][0].NodeID != remoteMiddleID {
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
}
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
t.Fatalf("expected remote middle node metadata in state")
}
}
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
}
if handler["retries"] != 2 {
t.Fatalf("expected retries 2 for 3 targets, got %v", handler["retries"])
}
}
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatalf("expected no retries for single target, got %v", handler["retries"])
}
}
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
handler := service["handler"].(map[string]interface{})
if _, hasChain := handler["chain"]; hasChain {
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatalf("expected no retries for exit role, got %v", handler["retries"])
}
metadata := service["metadata"].(map[string]interface{})
if metadata["interface"] != "eth0" {
t.Fatalf("expected interface 'eth0', got %v", metadata["interface"])
}
}
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
handler := service["handler"].(map[string]interface{})
meta := handler["metadata"].(map[string]interface{})
if meta["nodelay"] != true {
t.Fatalf("expected nodelay=true for TLS protocol, got %v", meta["nodelay"])
}
}
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
handler := service["handler"].(map[string]interface{})
if _, hasMeta := handler["metadata"]; hasMeta {
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
}
}
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "limited-share",
NodeID: 1,
Token: "limited-token",
MaxBandwidth: 2048,
CurrentFlow: 2048,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
body, err := json.Marshal(map[string]interface{}{
"resourceKey": "tunnel:1:node:1:type:3:hop:0",
"protocol": "tls",
"requestedPort": 0,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer limited-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationRuntimeReservePort(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 403 {
t.Fatalf("expected response code 403, got %d (%s)", payload.Code, payload.Msg)
}
if payload.Msg != "Share traffic limit exceeded" {
t.Fatalf("unexpected response message: %q", payload.Msg)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,671 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"log"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
type userTunnelPolicy struct {
ID int64
UserID int64
TunnelID int64
Flow int64
InFlow int64
OutFlow int64
ExpTime int64
Status int
Num int
}
type gostConfigSnapshot struct {
Services []namedConfigItem `json:"services"`
Chains []namedConfigItem `json:"chains"`
Limiters []namedConfigItem `json:"limiters"`
}
type namedConfigItem struct {
Name string `json:"name"`
}
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
return
}
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if ok {
if h.forwardExists(forwardID) {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
}
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
} else if nodeID > 0 {
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
}
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
return
}
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
if !ok {
return
}
h.processPeerShareFlow(runtimeID, item)
}
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
parts := strings.Split(serviceName, "_")
if len(parts) < 3 {
return 0, 0, 0, false
}
forwardID, err1 := strconv.ParseInt(parts[0], 10, 64)
userID, err2 := strconv.ParseInt(parts[1], 10, 64)
userTunnelID, err3 := strconv.ParseInt(parts[2], 10, 64)
if err1 != nil || err2 != nil || err3 != nil || forwardID <= 0 || userID <= 0 {
return 0, 0, 0, false
}
return forwardID, userID, userTunnelID, true
}
func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
const prefix = "fed_svc_"
if !strings.HasPrefix(serviceName, prefix) {
return 0, false
}
raw := strings.TrimPrefix(serviceName, prefix)
if raw == "" {
return 0, false
}
parts := strings.SplitN(raw, "_", 2)
runtimeID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || runtimeID <= 0 {
return 0, false
}
return runtimeID, true
}
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, 0, false
}
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
port, err := strconv.Atoi(portValue)
if err != nil || port <= 0 {
return 0, 0, false
}
return shareID, port, true
}
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, false
}
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
}
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
share, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
name := strings.TrimSpace(serviceName)
if strings.HasSuffix(name, "_tcp") {
return strings.TrimSuffix(name, "_tcp")
}
if strings.HasSuffix(name, "_udp") {
return strings.TrimSuffix(name, "_udp")
}
return name
}
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
var runtimes []model.PeerShareRuntime
var err error
// Try node-scoped query first if nodeID is valid
if nodeID > 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
if err != nil {
return
}
}
}
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
if len(runtimes) == 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
}
if len(runtimes) != 1 {
if len(runtimes) > 1 {
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
}
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil || len(runtimes) == 0 {
return
}
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
}
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
return inFlow, outFlow
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil {
return inFlow, outFlow
}
scaledIn := int64(float64(inFlow)*tunnel.TrafficRatio) * tunnel.Flow
scaledOut := int64(float64(outFlow)*tunnel.TrafficRatio) * tunnel.Flow
return scaledIn, scaledOut
}
func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
now := time.Now().UnixMilli()
if h.shouldPauseUser(userID, now) {
h.pauseUserForwards(userID, now)
}
policy, err := h.getUserTunnelPolicy(userTunnelID)
if err != nil || policy == nil {
return
}
if shouldPauseUserTunnel(policy, now) {
h.pauseUserTunnelForwards(policy.UserID, policy.TunnelID, now)
}
}
func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, now int64) error {
if h == nil || h.repo == nil {
return errors.New("invalid flow policy context")
}
if userID <= 0 || tunnelID <= 0 {
return nil
}
user, err := h.repo.GetUserByID(userID)
if err != nil {
return err
}
if user == nil {
return errors.New("用户不存在")
}
if user.Status != 1 {
return errors.New("账号已禁用")
}
if user.ExpTime > 0 && user.ExpTime <= now {
return errors.New("账号已过期")
}
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return errors.New("流量已超额,禁止开启转发")
}
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
return err
}
if user.Num > 0 {
currentForwardCount, err := h.repo.CountActiveForwardsByUser(userID)
if err != nil {
return err
}
if currentForwardCount >= int64(user.Num) {
return errors.New("转发数量已达上限")
}
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
if err != nil {
return err
}
if userTunnelID <= 0 {
return nil
}
policy, err := h.getUserTunnelPolicy(userTunnelID)
if err != nil {
return err
}
if policy == nil {
return nil
}
if policy.Status != 1 {
return errors.New("该隧道已禁用")
}
if policy.ExpTime > 0 && policy.ExpTime <= now {
return errors.New("该隧道已过期")
}
utFlowLimit := policy.Flow * bytesPerGB
utCurrent := policy.InFlow + policy.OutFlow
if utCurrent >= utFlowLimit {
return errors.New("该隧道流量已超额,禁止开启转发")
}
if policy.Num > 0 {
currentTunnelForwardCount, err := h.repo.CountActiveForwardsByUserTunnel(userID, tunnelID)
if err != nil {
return err
}
if currentTunnelForwardCount >= int64(policy.Num) {
return errors.New("该隧道转发数量已达上限")
}
}
return nil
}
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil {
return false
}
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return true
}
if user.ExpTime > 0 && user.ExpTime <= now {
return true
}
return user.Status != 1
}
func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
if policy == nil {
return false
}
flowLimit := policy.Flow * bytesPerGB
current := policy.InFlow + policy.OutFlow
if current >= flowLimit {
return true
}
if policy.ExpTime > 0 && policy.ExpTime <= now {
return true
}
return policy.Status != 1
}
func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, error) {
if userTunnelID <= 0 {
return nil, nil
}
ut, err := h.repo.GetUserTunnelByID(userTunnelID)
if err != nil {
return nil, err
}
if ut == nil {
return nil, nil
}
return &userTunnelPolicy{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
}, nil
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return
}
h.pauseForwardRecords(forwards, now)
}
func (h *Handler) pauseUserTunnelForwards(userID int64, tunnelID int64, now int64) {
forwards, err := h.listActiveForwardsByUserTunnel(userID, tunnelID)
if err != nil {
return
}
h.pauseForwardRecords(forwards, now)
}
func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
for i := range forwards {
forward := forwards[i]
_ = h.controlForwardServices(&forward, "PauseService", false)
_ = h.repo.UpdateForwardStatus(forward.ID, 0, now)
}
}
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
return h.repo.ListActiveForwardsByUser(userID)
}
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
return h.repo.ListActiveForwardsByUserTunnel(userID, tunnelID)
}
func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
if h == nil || h.repo == nil || nodeID <= 0 {
return
}
if strings.TrimSpace(rawConfig) == "" {
return
}
var snapshot gostConfigSnapshot
if err := json.Unmarshal([]byte(rawConfig), &snapshot); err != nil {
return
}
h.cleanOrphanedServices(nodeID, snapshot.Services)
h.cleanOrphanedChains(nodeID, snapshot.Chains)
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters)
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
return
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
parts := strings.Split(name, "_")
if len(parts) == 2 && parts[0] == "tunnel" {
tunnelID, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
}
continue
}
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
}
}
suffix := parts[len(parts)-1]
switch suffix {
case "tls", "kcp", "wss", "mtls", "mwss", "mtcp":
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
case "tcp":
if len(parts) < 4 {
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
}
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
base := strings.TrimSuffix(name, "_tcp")
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{base + "_tcp", base + "_udp"}}, false, true)
}
}
}
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
for _, item := range chains {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
idx := strings.LastIndex(name, "_")
if idx <= 0 || idx >= len(name)-1 {
continue
}
tunnelID, err := strconv.ParseInt(name[idx+1:], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": name}, false, true)
}
}
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem) {
for _, item := range limiters {
name := strings.TrimSpace(item.Name)
if name == "" || h.speedLimiterExists(name) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", map[string]interface{}{"limiter": name}, false, true)
}
}
func (h *Handler) tunnelExists(tunnelID int64) bool {
ok, _ := h.repo.TunnelExists(tunnelID)
return ok
}
func (h *Handler) forwardExists(forwardID int64) bool {
ok, _ := h.repo.ForwardExists(forwardID)
return ok
}
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
parts := strings.Split(serviceName, "_")
if len(parts) < 3 {
return
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || forwardID <= 0 {
return
}
base := parts[0] + "_" + parts[1] + "_" + parts[2]
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
"services": []string{base + "_tcp", base + "_udp"},
}, false, true)
}
func (h *Handler) speedLimiterExists(name string) bool {
if name == "" {
return false
}
id, err := strconv.ParseInt(name, 10, 64)
if err != nil || id <= 0 {
return false
}
ok, _ := h.repo.SpeedLimitExists(id)
return ok
}

Some files were not shown because too many files have changed in this diff Show More