From 116e00b7f0b0e6c3dfc8329e997a56bc7b6a3d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 00:11:43 +0800 Subject: [PATCH 01/25] =?UTF-8?q?feat(leases):=20=E7=A7=9F=E7=BA=A6?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20UI=20=E4=BC=98=E5=8C=96=20+=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=20API=20+=20Dev=20Build=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 后端 LeaseResponse: 新增 created_at 字段,leaseToResponse 转换 CreatedAtNs - handler_lease: leaseSortKey 扩展至 6 个排序字段(account/node_tag/egress_ip/created_at/expiry/last_accessed) - server.go: 注册 PUT /leases/{account} 绑定租约路由 - PlatformLeasesPanel.tsx: 新增完整租约面板组件(搜索/排序/分页/绑定/解绑) - api.ts: 新增 listPlatformLeases、deletePlatformLease、bindPlatformLease API - types.ts: LeaseResponse 新增 created_at 字段 - theme.css: 租约面板、绑定表单、排序列头样式 - .github/workflows/dev-build.yml: 新增 Dev Build CI workflow(手动触发、多平台构建+Docker) - .gitignore: 更新忽略规则 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .github/workflows/dev-build.yml | 190 ++++++++++++ .gitignore | 9 +- internal/api/handler_lease.go | 37 ++- internal/api/server.go | 1 + internal/service/control_plane_leases.go | 57 ++++ webui/package-lock.json | 19 +- .../features/platforms/PlatformDetailPage.tsx | 15 +- .../platforms/PlatformLeasesPanel.tsx | 293 ++++++++++++++++++ webui/src/features/platforms/api.ts | 47 ++- webui/src/features/platforms/types.ts | 11 + webui/src/styles/theme.css | 72 +++++ 11 files changed, 729 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/dev-build.yml create mode 100644 webui/src/features/platforms/PlatformLeasesPanel.tsx diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..47d3c5d3 --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,190 @@ +name: Dev Build + +on: + workflow_dispatch: + inputs: + version_suffix: + description: 'Version suffix (leave empty for auto: dev-YYYYMMDD-sha8)' + required: false + type: string + +concurrency: + group: dev-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-binaries: + name: Build Binaries + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + # Auto-generate: dev-YYYYMMDD-sha8 + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + # Already has dev- prefix + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build WebUI + working-directory: ./webui + run: | + npm ci + npm run build + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + + - name: Build Go Binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${{ steps.version.outputs.version }} + GIT_COMMIT=${GITHUB_SHA::8} + BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + + OUTPUT_NAME=resin-${GOOS}-${GOARCH} + if [ "$GOOS" = "windows" ]; then + OUTPUT_NAME="${OUTPUT_NAME}.exe" + fi + echo "OUTPUT_NAME=${OUTPUT_NAME}" >> $GITHUB_ENV + + mkdir -p build + + go build -trimpath -tags "with_quic with_wireguard with_grpc with_utls with_embedded_tor with_naive_outbound" \ + -ldflags="-s -w \ + -X github.com/Resinat/Resin/internal/buildinfo.Version=${VERSION} \ + -X github.com/Resinat/Resin/internal/buildinfo.GitCommit=${GIT_COMMIT} \ + -X github.com/Resinat/Resin/internal/buildinfo.BuildTime=${BUILD_TIME}" \ + -o build/${OUTPUT_NAME} ./cmd/resin + + cd build + + SIMPLE_NAME="resin" + if [ "$GOOS" = "windows" ]; then + SIMPLE_NAME="resin.exe" + fi + cp ${OUTPUT_NAME} ${SIMPLE_NAME} + + if [ "$GOOS" = "windows" ]; then + zip resin-${GOOS}-${GOARCH}.zip ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.zip" + else + tar -czvf resin-${GOOS}-${GOARCH}.tar.gz ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.tar.gz" + fi + + rm ${SIMPLE_NAME} + echo "PACKAGE_NAME=${PACKAGE_NAME}" >> $GITHUB_ENV + + - name: Upload Release Package Artifact + uses: actions/upload-artifact@v4 + with: + name: dev-release-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.PACKAGE_NAME }} + retention-days: 7 + + - name: Upload Linux bin for Docker + if: matrix.goos == 'linux' + uses: actions/upload-artifact@v4 + with: + name: dev-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.OUTPUT_NAME }} + retention-days: 1 + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: build-binaries + permissions: + contents: read + packages: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download Linux amd64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-amd64 + path: release-bin/linux/amd64/ + + - name: Download Linux arm64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-arm64 + path: release-bin/linux/arm64/ + + - name: Give binaries execute permission + run: chmod +x release-bin/linux/amd64/resin-linux-amd64 release-bin/linux/arm64/resin-linux-arm64 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./.github/Dockerfile.release + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} + ghcr.io/${{ github.repository }}:dev-latest diff --git a/.gitignore b/.gitignore index 6bad45d3..53c5b2e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ /resin sing-box-reference -.agents \ No newline at end of file +.agents +.ace-tool/ +.claude/ +.cursor/ +.trellis/ +AGENTS.md +data/ +start.sh \ No newline at end of file diff --git a/internal/api/handler_lease.go b/internal/api/handler_lease.go index 15757a99..161e4ca2 100644 --- a/internal/api/handler_lease.go +++ b/internal/api/handler_lease.go @@ -19,6 +19,12 @@ func validateAccountPath(r *http.Request) (string, error) { func leaseSortKey(sortBy string, l service.LeaseResponse) string { switch sortBy { + case "node_tag": + return l.NodeTag + case "egress_ip": + return l.EgressIP + case "created_at": + return l.CreatedAt case "expiry": return l.Expiry case "last_accessed": @@ -92,7 +98,7 @@ func HandleListLeases(cp *service.ControlPlaneService) http.HandlerFunc { leases = filtered } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "expiry", "last_accessed"}, "expiry", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "node_tag", "egress_ip", "created_at", "expiry", "last_accessed"}, "expiry", "asc") if !ok { return } @@ -164,6 +170,35 @@ func HandleDeleteAllLeases(cp *service.ControlPlaneService) http.HandlerFunc { } } +// HandleBindLease returns a handler for PUT /api/v1/platforms/{id}/leases/{account}. +func HandleBindLease(cp *service.ControlPlaneService) http.HandlerFunc { + type bindRequest struct { + NodeHash string `json:"node_hash"` + } + return func(w http.ResponseWriter, r *http.Request) { + platformID, ok := requireUUIDPathParam(w, r, "id", "platform_id") + if !ok { + return + } + account, err := validateAccountPath(r) + if err != nil { + writeServiceError(w, err) + return + } + var req bindRequest + if err := DecodeBody(r, &req); err != nil { + writeDecodeBodyError(w, err) + return + } + lease, err := cp.BindLease(platformID, account, req.NodeHash) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, lease) + } +} + // HandleIPLoad returns a handler for GET /api/v1/platforms/{id}/ip-load. func HandleIPLoad(cp *service.ControlPlaneService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/server.go b/internal/api/server.go index fa070244..ce043b9a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,6 +89,7 @@ func NewServerWithAddress( authed.Handle("GET /api/v1/platforms/{id}/leases", HandleListLeases(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases", HandleDeleteAllLeases(cp)) authed.Handle("GET /api/v1/platforms/{id}/leases/{account}", HandleGetLease(cp)) + authed.Handle("PUT /api/v1/platforms/{id}/leases/{account}", HandleBindLease(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases/{account}", HandleDeleteLease(cp)) authed.Handle("GET /api/v1/platforms/{id}/ip-load", HandleIPLoad(cp)) diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index 1702e2ba..6f9f6e57 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -20,6 +20,7 @@ type LeaseResponse struct { NodeHash string `json:"node_hash"` NodeTag string `json:"node_tag"` EgressIP string `json:"egress_ip"` + CreatedAt string `json:"created_at"` Expiry string `json:"expiry"` LastAccessed string `json:"last_accessed"` } @@ -31,6 +32,7 @@ func leaseToResponse(lease model.Lease, nodeTag string) LeaseResponse { NodeHash: lease.NodeHash, NodeTag: nodeTag, EgressIP: lease.EgressIP, + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), } @@ -63,6 +65,7 @@ func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, er Account: account, NodeHash: lease.NodeHash.Hex(), EgressIP: lease.EgressIP.String(), + CreatedAtNs: lease.CreatedAtNs, ExpiryNs: lease.ExpiryNs, LastAccessedNs: lease.LastAccessedNs, }, s.resolveLeaseNodeTag(lease.NodeHash))) @@ -148,6 +151,60 @@ func (s *ControlPlaneService) DeleteAllLeases(platformID string) error { return nil } +// BindLease binds (or rebinds) an account to a specific node on the given platform. +// The node must be routable on the platform. +func (s *ControlPlaneService) BindLease(platformID, account, nodeHashHex string) (*LeaseResponse, error) { + account = strings.TrimSpace(account) + if account == "" { + return nil, invalidArg("account: must be non-empty") + } + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + + plat, ok := s.Pool.GetPlatform(platformID) + if !ok { + return nil, notFound("platform not found") + } + + if !plat.View().Contains(h) { + return nil, notFound("node is not routable on this platform") + } + + entry, ok := s.Pool.GetEntry(h) + if !ok { + return nil, notFound("node not found") + } + egressIP := entry.GetEgressIP() + if !egressIP.IsValid() { + return nil, invalidArg("node has no egress IP") + } + + nowNs := time.Now().UnixNano() + ttlNs := plat.StickyTTLNs + if ttlNs <= 0 { + ttlNs = int64(24 * time.Hour) // default 24h + } + + ml := model.Lease{ + PlatformID: platformID, + Account: account, + NodeHash: h.Hex(), + EgressIP: egressIP.String(), + CreatedAtNs: nowNs, + ExpiryNs: nowNs + ttlNs, + LastAccessedNs: nowNs, + } + if err := s.Router.UpsertLease(ml); err != nil { + return nil, internal("bind lease", err) + } + + resp := leaseToResponse(ml, s.resolveLeaseNodeTag(h)) + return &resp, nil +} + // IPLoadEntry is the API response for IP load stats. type IPLoadEntry struct { EgressIP string `json:"egress_ip"` diff --git a/webui/package-lock.json b/webui/package-lock.json index 88505cff..96b10159 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1666,7 +1665,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1677,7 +1675,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1743,7 +1740,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2008,7 +2004,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2117,7 +2112,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2491,7 +2485,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2911,7 +2904,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3307,7 +3299,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3369,7 +3360,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3379,7 +3369,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3392,7 +3381,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -3443,7 +3431,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3538,8 +3525,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3747,7 +3733,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3865,7 +3850,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3995,7 +3979,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/src/features/platforms/PlatformDetailPage.tsx b/webui/src/features/platforms/PlatformDetailPage.tsx index b0c64172..829d5035 100644 --- a/webui/src/features/platforms/PlatformDetailPage.tsx +++ b/webui/src/features/platforms/PlatformDetailPage.tsx @@ -33,12 +33,14 @@ import { type PlatformFormValues, } from "./formModel"; import { PlatformMonitorPanel } from "./PlatformMonitorPanel"; +import { PlatformLeasesPanel } from "./PlatformLeasesPanel"; -type PlatformDetailTab = "monitor" | "config" | "ops"; +type PlatformDetailTab = "monitor" | "leases" | "config" | "ops"; const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; const DETAIL_TABS: Array<{ key: PlatformDetailTab; label: string; hint: string }> = [ { key: "monitor", label: "监控", hint: "平台运行态趋势和快照" }, + { key: "leases", label: "租约", hint: "查看和管理当前平台的租约绑定" }, { key: "config", label: "配置", hint: "过滤规则与分配策略" }, { key: "ops", label: "运维", hint: "重置、清租约、删除操作" }, ]; @@ -315,6 +317,17 @@ export function PlatformDetailPage() { ) : null} + {activeTab === "leases" ? ( +
+ +
+ ) : null} + {activeTab === "config" ? (
(); + +type SortField = "account" | "node_tag" | "egress_ip" | "created_at" | "expiry" | "last_accessed"; +type SortOrder = "asc" | "desc"; + +type Props = { + platform: Platform; + showToast: (type: "success" | "error", message: string) => void; +}; + +export function PlatformLeasesPanel({ platform, showToast }: Props) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(50); + const [bindOpen, setBindOpen] = useState(false); + const [bindAccount, setBindAccount] = useState(""); + const [selectedNodeHash, setSelectedNodeHash] = useState(""); + const [sortBy, setSortBy] = useState("account"); + const [sortOrder, setSortOrder] = useState("asc"); + + const queryKey = ["platform-leases", platform.id, search, page, pageSize, sortBy, sortOrder]; + + const leasesQuery = useQuery({ + queryKey, + queryFn: () => + listPlatformLeases(platform.id, { + limit: pageSize, + offset: page * pageSize, + account: search || undefined, + fuzzy: true, + sort_by: sortBy, + sort_order: sortOrder, + }), + refetchInterval: 15_000, + placeholderData: (prev) => prev, + }); + + const leasesPage = leasesQuery.data ?? { items: [], total: 0, limit: pageSize, offset: 0 }; + const leases = leasesPage.items; + const totalPages = Math.max(1, Math.ceil(leasesPage.total / pageSize)); + + const nodesQuery = useQuery({ + queryKey: ["platform-nodes", platform.id], + queryFn: () => listNodes({ platform_id: platform.id, limit: 10000 }), + enabled: bindOpen, + }); + + const sortedNodes = (nodesQuery.data?.items ?? []).slice().sort((a, b) => { + const aLat = a.reference_latency_ms; + const bLat = b.reference_latency_ms; + if (aLat == null && bLat == null) return 0; + if (aLat == null) return 1; + if (bLat == null) return -1; + return aLat - bLat; + }); + + const invalidateLeases = async () => { + await queryClient.invalidateQueries({ queryKey: ["platform-leases", platform.id] }); + await queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }); + }; + + const deleteMutation = useMutation({ + mutationFn: (account: string) => deletePlatformLease(platform.id, account), + onSuccess: async (_, account) => { + await invalidateLeases(); + showToast("success", t("租约 {{account}} 已解绑", { account })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const bindMutation = useMutation({ + mutationFn: () => bindPlatformLease(platform.id, bindAccount.trim(), selectedNodeHash), + onSuccess: async (lease) => { + await invalidateLeases(); + setBindOpen(false); + setBindAccount(""); + setSelectedNodeHash(""); + showToast("success", t("租约 {{account}} 已绑定到 {{ip}}", { account: lease.account, ip: lease.egress_ip })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const handleDelete = (account: string) => { + const confirmed = window.confirm(t("确认解绑租约 {{account}}?", { account })); + if (confirmed) { + deleteMutation.mutate(account); + } + }; + + const handleBind = (e: React.FormEvent) => { + e.preventDefault(); + if (!bindAccount.trim() || !selectedNodeHash) return; + bindMutation.mutate(); + }; + + const changePageSize = (size: number) => { + setPageSize(size); + setPage(0); + }; + + const toggleSort = (field: SortField) => { + if (sortBy === field) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortBy(field); + setSortOrder("asc"); + } + setPage(0); + }; + + const sortHeader = (label: string, field: SortField): ReactNode => { + const active = sortBy === field; + const Icon = active ? (sortOrder === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; + return ( + toggleSort(field)}> + {label} + + + ); + }; + + const leaseColumns = [ + columnHelper.accessor("account", { + header: () => sortHeader(t("Account"), "account"), + cell: (info) => {info.getValue()}, + }), + columnHelper.accessor("node_tag", { + header: () => sortHeader(t("节点"), "node_tag"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("egress_ip", { + header: () => sortHeader(t("出口 IP"), "egress_ip"), + }), + columnHelper.accessor("created_at", { + header: () => sortHeader(t("绑定时间"), "created_at"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("expiry", { + header: () => sortHeader(t("到期时间"), "expiry"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("last_accessed", { + header: () => sortHeader(t("最近访问"), "last_accessed"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.display({ + id: "actions", + header: "", + cell: (info) => ( + + ), + }), + ]; + + return ( +
+
+
+ + { + setSearch(e.target.value); + setPage(0); + }} + /> +
+ +
+ + {bindOpen ? ( +
+
+ setBindAccount(e.target.value)} + required + /> +
+
+ +
+
+ + +
+
+ ) : null} + + {leasesQuery.isLoading ?

{t("正在加载租约数据...")}

: null} + + {leasesQuery.isError ? ( +
+ + {formatApiErrorMessage(leasesQuery.error, t)} +
+ ) : null} + + {!leasesQuery.isLoading && !leases.length ? ( +
+ +

{t("没有租约")}

+
+ ) : null} + + {leases.length ? ( + l.account} /> + ) : null} + + +
+ ); +} diff --git a/webui/src/features/platforms/api.ts b/webui/src/features/platforms/api.ts index fbd39bce..b87848f6 100644 --- a/webui/src/features/platforms/api.ts +++ b/webui/src/features/platforms/api.ts @@ -1,5 +1,5 @@ import { apiRequest } from "../../lib/api-client"; -import type { PageResponse, Platform, PlatformCreateInput, PlatformUpdateInput } from "./types"; +import type { LeaseResponse, PageResponse, Platform, PlatformCreateInput, PlatformUpdateInput } from "./types"; const basePath = "/api/v1/platforms"; @@ -111,3 +111,48 @@ export async function clearAllPlatformLeases(id: string): Promise { method: "DELETE", }); } + +export type ListLeasesInput = { + limit?: number; + offset?: number; + account?: string; + fuzzy?: boolean; + sort_by?: string; + sort_order?: string; +}; + +export async function listPlatformLeases( + platformId: string, + input: ListLeasesInput = {}, +): Promise> { + const query = new URLSearchParams({ + limit: String(input.limit ?? 50), + offset: String(input.offset ?? 0), + }); + if (input.account?.trim()) { + query.set("account", input.account.trim()); + if (input.fuzzy !== false) { + query.set("fuzzy", "true"); + } + } + if (input.sort_by) query.set("sort_by", input.sort_by); + if (input.sort_order) query.set("sort_order", input.sort_order); + return apiRequest>(`${basePath}/${platformId}/leases?${query.toString()}`); +} + +export async function deletePlatformLease(platformId: string, account: string): Promise { + await apiRequest(`${basePath}/${platformId}/leases/${encodeURIComponent(account)}`, { + method: "DELETE", + }); +} + +export async function bindPlatformLease( + platformId: string, + account: string, + nodeHash: string, +): Promise { + return apiRequest( + `${basePath}/${platformId}/leases/${encodeURIComponent(account)}`, + { method: "PUT", body: { node_hash: nodeHash } }, + ); +} diff --git a/webui/src/features/platforms/types.ts b/webui/src/features/platforms/types.ts index a47a1feb..29bff3e8 100644 --- a/webui/src/features/platforms/types.ts +++ b/webui/src/features/platforms/types.ts @@ -44,3 +44,14 @@ export type PlatformUpdateInput = { reverse_proxy_fixed_account_header?: string; allocation_policy?: PlatformAllocationPolicy; }; + +export type LeaseResponse = { + platform_id: string; + account: string; + node_hash: string; + node_tag: string; + egress_ip: string; + created_at: string; + expiry: string; + last_accessed: string; +}; diff --git a/webui/src/styles/theme.css b/webui/src/styles/theme.css index 413808b3..79ac2b61 100644 --- a/webui/src/styles/theme.css +++ b/webui/src/styles/theme.css @@ -2591,6 +2591,78 @@ a { justify-content: flex-end; } +/* ── Lease panel ── */ +.platform-leases-panel { + display: flex; + flex-direction: column; + gap: 10px; +} +.platform-leases-toolbar { + display: flex; + align-items: center; + gap: 8px; +} +.platform-leases-search { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + max-width: 320px; +} +.platform-leases-search .form-input { + flex: 1; +} +.platform-leases-bind-form { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(37, 72, 120, 0.14); + border-radius: 10px; + background: rgba(255, 255, 255, 0.84); + flex-wrap: nowrap; +} +.bind-field { + min-width: 0; +} +.bind-field-account { + flex: 1; +} +.bind-field-node { + flex: 2; +} +.bind-field .form-input, +.bind-field .form-select { + width: 100%; +} +.bind-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.bind-actions .btn { + white-space: nowrap; +} +.lease-account-cell { + font-family: var(--font-mono, monospace); + font-size: 12px; +} +.lease-sort-header { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; + color: var(--text-muted); + transition: color 0.15s; +} +.lease-sort-header:hover { + color: var(--text); +} +.lease-sort-header.active { + color: var(--primary); +} + .platform-ops-list { --platform-op-btn-width: 164px; display: flex; From aa591a0443637d400c9e2609471176b2465e79b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 00:19:34 +0800 Subject: [PATCH 02/25] =?UTF-8?q?fix(ci):=20Docker=20tag=20=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=90=8D=E8=BD=AC=E5=B0=8F=E5=86=99=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20buildx=20=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - dev-build.yml: 新增 Lowercase repository name 步骤,将 github.repository 转为小写后用于 Docker tag via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .github/workflows/dev-build.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 47d3c5d3..55e3feda 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -171,6 +171,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Lowercase repository name + run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -186,5 +189,5 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: | - ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} - ghcr.io/${{ github.repository }}:dev-latest + ghcr.io/${{ env.REPO_LC }}:${{ steps.version.outputs.version }} + ghcr.io/${{ env.REPO_LC }}:dev-latest From 1e2202e6d431520d5420a26a91b4e6edac532264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 01:14:58 +0800 Subject: [PATCH 03/25] =?UTF-8?q?feat(theme):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9A=97=E9=BB=91=E4=B8=BB=E9=A2=98=20+=20=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - webui/index.html: 添加 FOUC 防闪脚本,DOM 渲染前设置 data-theme - webui/src/stores/theme-store.ts: 新建 Zustand theme store,支持 system/light/dark 三态切换 - webui/src/components/ThemeToggle.tsx: 新建主题切换按钮,复用 locale-switch-compact 样式 - webui/src/components/AppShell.tsx: 侧栏集成 ThemeToggle 组件 - webui/src/i18n/translations.ts: 新增切换主题/深色/浅色/系统 4 个翻译键 - webui/src/styles/theme.css: 追加 [data-theme="dark"] 暗色 CSS 变量覆盖及组件适配 - webui/src/components/ui/Switch.css: 追加 Switch 暗色滑块样式 --- webui/index.html | 9 + webui/src/components/AppShell.tsx | 2 + webui/src/components/ThemeToggle.tsx | 32 ++ webui/src/components/ui/Switch.css | 9 + webui/src/i18n/translations.ts | 4 + webui/src/stores/theme-store.ts | 70 +++++ webui/src/styles/theme.css | 447 +++++++++++++++++++++++++++ 7 files changed, 573 insertions(+) create mode 100644 webui/src/components/ThemeToggle.tsx create mode 100644 webui/src/stores/theme-store.ts diff --git a/webui/index.html b/webui/index.html index 939be235..5edaa66b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7,6 +7,15 @@ Resin · Sticky Proxy Pool +
diff --git a/webui/src/components/AppShell.tsx b/webui/src/components/AppShell.tsx index f4eccd5e..375a896f 100644 --- a/webui/src/components/AppShell.tsx +++ b/webui/src/components/AppShell.tsx @@ -19,6 +19,7 @@ import { useAuthStore } from "../features/auth/auth-store"; import { getEnvConfig } from "../features/systemConfig/api"; import { useI18n } from "../i18n"; import { LanguageSwitcher } from "./LanguageSwitcher"; +import { ThemeToggle } from "./ThemeToggle"; type NavItem = { label: string; @@ -133,6 +134,7 @@ export function AppShell() { ) : (
)} - -
+ +
diff --git a/webui/src/features/systemConfig/api.ts b/webui/src/features/systemConfig/api.ts index 9fca17f5..f55de414 100644 --- a/webui/src/features/systemConfig/api.ts +++ b/webui/src/features/systemConfig/api.ts @@ -1,3 +1,4 @@ +import { getStoredAuthToken } from "../auth/auth-store"; import { apiRequest } from "../../lib/api-client"; import type { EnvConfig, RuntimeConfig, RuntimeConfigPatch } from "./types"; @@ -105,3 +106,52 @@ export async function patchSystemConfig(patch: RuntimeConfigPatch): Promise { return await apiRequest(path + "/env"); } + +// --- Data export / import --- + +export type ImportResult = { + platforms_created: number; + platforms_skipped: number; + platforms_overwritten: number; + subscriptions_created: number; + subscriptions_skipped: number; + subscriptions_overwritten: number; + errors: string[]; +}; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ""; + +export async function exportData(): Promise { + const token = getStoredAuthToken(); + const headers: HeadersInit = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const response = await fetch(`${API_BASE_URL}/api/v1/data/export`, { headers }); + if (!response.ok) { + throw new Error(`Export failed: ${response.statusText}`); + } + const blob = await response.blob(); + const disposition = response.headers.get("Content-Disposition") ?? ""; + const match = disposition.match(/filename="?([^"]+)"?/); + const filename = match?.[1] ?? "resin-export.json"; + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export async function importData( + payload: unknown, + strategy: "skip" | "overwrite", +): Promise { + return apiRequest(`/api/v1/data/import?strategy=${strategy}`, { + method: "POST", + body: payload as Record, + }); +} diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts index 8d40cc83..b8812326 100644 --- a/webui/src/i18n/translations.ts +++ b/webui/src/i18n/translations.ts @@ -625,6 +625,23 @@ const EXACT_ZH_TO_EN: Record = { "总请求": "Total requests", "最近错误:{{message}}": "Recent error: {{message}}", "配置已更新({{count}} 项变更)": "Config updated ({{count}} changes)", + "数据管理": "Data Management", + "导出平台与订阅配置为 JSON 文件,用于备份或迁移。": "Export platform and subscription configs as JSON for backup or migration.", + "导出 JSON": "Export JSON", + "导出中...": "Exporting...", + "导出成功": "Export successful", + "导入 JSON 文件以恢复平台与订阅配置。": "Import a JSON file to restore platform and subscription configs.", + "选择 JSON 文件": "Select JSON file", + "冲突策略": "Conflict strategy", + "跳过已存在": "Skip existing", + "覆盖已存在": "Overwrite existing", + "导入": "Import", + "导入中...": "Importing...", + "请先选择 JSON 文件": "Please select a JSON file first", + "JSON 文件解析失败": "Failed to parse JSON file", + "创建 {{count}} 项": "{{count}} created", + "跳过 {{count}} 项": "{{count}} skipped", + "覆盖 {{count}} 项": "{{count}} overwritten", }; export function translateDocumentTitle(locale: AppLocale): string { From 22c35aba371fcf7d88433baa151c7be0fa730346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 10:43:37 +0800 Subject: [PATCH 06/25] =?UTF-8?q?fix(data):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=A6=86=E7=9B=96=20remote=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=97=B6=20content=20=E5=AD=97=E6=AE=B5=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/service/control_plane_data.go: buildSubscriptionPatch 按 source_type 条件包含 url/content 字段 - webui/src/features/systemConfig/SystemConfigPage.tsx: 移除多余的闭合 div 标签 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- internal/service/control_plane_data.go | 8 ++++++-- webui/src/features/systemConfig/SystemConfigPage.tsx | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go index b4361b8a..9c033111 100644 --- a/internal/service/control_plane_data.go +++ b/internal/service/control_plane_data.go @@ -330,13 +330,17 @@ func buildCreateSubscriptionRequest(e ExportSubscriptionEntry) CreateSubscriptio func buildSubscriptionPatch(e ExportSubscriptionEntry) map[string]any { patch := map[string]any{ "name": strings.TrimSpace(e.Name), - "url": strings.TrimSpace(e.URL), - "content": e.Content, "update_interval": e.UpdateInterval, "enabled": e.Enabled, "ephemeral": e.Ephemeral, "ephemeral_node_evict_delay": e.EphemeralNodeEvictDelay, } + if e.SourceType == "remote" { + patch["url"] = strings.TrimSpace(e.URL) + } + if e.SourceType == "local" { + patch["content"] = e.Content + } return patch } diff --git a/webui/src/features/systemConfig/SystemConfigPage.tsx b/webui/src/features/systemConfig/SystemConfigPage.tsx index 8f3febb4..323d01fc 100644 --- a/webui/src/features/systemConfig/SystemConfigPage.tsx +++ b/webui/src/features/systemConfig/SystemConfigPage.tsx @@ -1172,7 +1172,6 @@ export function SystemConfigPage() {
-
)} From 354eab12361cacc7d1b5bba4acb35f4a4e3fe45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 12:01:27 +0800 Subject: [PATCH 07/25] =?UTF-8?q?fix(webui):=20=E4=BF=AE=E5=A4=8D=20import?= =?UTF-8?q?Data=20=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - webui/src/lib/api-client.ts: 导出 JsonValue 类型 - webui/src/features/systemConfig/api.ts: importData body 使用 JsonValue 替代 Record via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- webui/src/features/systemConfig/api.ts | 4 ++-- webui/src/lib/api-client.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webui/src/features/systemConfig/api.ts b/webui/src/features/systemConfig/api.ts index f55de414..43d41ec4 100644 --- a/webui/src/features/systemConfig/api.ts +++ b/webui/src/features/systemConfig/api.ts @@ -1,5 +1,5 @@ import { getStoredAuthToken } from "../auth/auth-store"; -import { apiRequest } from "../../lib/api-client"; +import { apiRequest, type JsonValue } from "../../lib/api-client"; import type { EnvConfig, RuntimeConfig, RuntimeConfigPatch } from "./types"; const path = "/api/v1/system/config"; @@ -152,6 +152,6 @@ export async function importData( ): Promise { return apiRequest(`/api/v1/data/import?strategy=${strategy}`, { method: "POST", - body: payload as Record, + body: payload as JsonValue, }); } diff --git a/webui/src/lib/api-client.ts b/webui/src/lib/api-client.ts index 085a0c50..4a1de2fe 100644 --- a/webui/src/lib/api-client.ts +++ b/webui/src/lib/api-client.ts @@ -3,7 +3,7 @@ import { getStoredAuthToken } from "../features/auth/auth-store"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ""; type Primitive = string | number | boolean | null; -type JsonValue = Primitive | JsonValue[] | { [key: string]: JsonValue }; +export type JsonValue = Primitive | JsonValue[] | { [key: string]: JsonValue }; export type ApiErrorBody = { error?: { From 9748d25549954bbe3a44444b16351abddf353fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 17:38:33 +0800 Subject: [PATCH 08/25] =?UTF-8?q?fix(data):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=A6=86=E7=9B=96=E5=B9=B3=E5=8F=B0=E6=97=B6=20region?= =?UTF-8?q?=5Ffilters/regex=5Ffilters=20null=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/service/control_plane_data.go: buildPlatformPatch 中将 nil slice 转为空 slice,避免 JSON 序列化为 null 被 validateFields 拒绝 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- internal/service/control_plane_data.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go index 9c033111..dc295299 100644 --- a/internal/service/control_plane_data.go +++ b/internal/service/control_plane_data.go @@ -296,12 +296,20 @@ func buildCreatePlatformRequest(e ExportPlatformEntry) CreatePlatformRequest { } func buildPlatformPatch(e ExportPlatformEntry) map[string]any { + regexFilters := e.RegexFilters + if regexFilters == nil { + regexFilters = []string{} + } + regionFilters := e.RegionFilters + if regionFilters == nil { + regionFilters = []string{} + } patch := map[string]any{ - "sticky_ttl": e.StickyTTL, - "regex_filters": e.RegexFilters, - "region_filters": e.RegionFilters, - "allocation_policy": e.AllocationPolicy, - "reverse_proxy_miss_action": e.ReverseProxyMissAction, + "sticky_ttl": e.StickyTTL, + "regex_filters": regexFilters, + "region_filters": regionFilters, + "allocation_policy": e.AllocationPolicy, + "reverse_proxy_miss_action": e.ReverseProxyMissAction, "reverse_proxy_empty_account_behavior": e.ReverseProxyEmptyAccountBehavior, "reverse_proxy_fixed_account_header": e.ReverseProxyFixedAccountHeader, } From 3f74d7638cbdbb059a69db328bd333547e8c3421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Tue, 12 May 2026 15:03:00 +0800 Subject: [PATCH 09/25] =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=B1=A0=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=A7=9F=E7=BA=A6=E6=95=B0=E5=B1=95=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E7=A7=9F=E7=BA=A6=E5=BC=B9=E7=AA=97=E8=AF=A6=E6=83=85=E4=B8=8E?= =?UTF-8?q?=E8=A7=A3=E7=BB=91=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 节点租约数列: 后端聚合 lease_count 字段(router 新增 SnapshotNodeLoad/SnapshotNodeLoadAll),前端新增可排序列,点击打开租约弹窗 - 租约详情弹窗: 新增 NodeLeasesModal 组件,展示平台/账号/绑定时间/到期/最近访问,支持解绑操作 - 弹窗节点元信息: 租约表上方展示节点来源、出口 IP、参考延迟三个 chip 指标 - 共享格式函数: 提取 nodeFormat.ts,NodesPage 与弹窗复用 firstTag/regionToFlag/formatLatency 等工具函数 - 节点租约 API: 新增 GET /api/v1/nodes/{hash}/leases 端点,service 层新增 ListLeasesByNode - 弹窗样式优化: 修复表格横向溢出,新增 modal 卡片/table 覆写/chip 行等样式 --- internal/api/handler_node.go | 27 ++- internal/api/server.go | 1 + internal/routing/router.go | 46 ++++ internal/service/control_plane_leases.go | 99 +++++++++ internal/service/control_plane_nodes.go | 19 ++ internal/service/control_plane_platform.go | 1 + webui/src/features/nodes/NodeLeasesModal.tsx | 213 +++++++++++++++++++ webui/src/features/nodes/NodesPage.tsx | 132 +++++------- webui/src/features/nodes/api.ts | 17 +- webui/src/features/nodes/nodeFormat.ts | 80 +++++++ webui/src/features/nodes/types.ts | 14 +- webui/src/styles/theme.css | 98 +++++++++ 12 files changed, 667 insertions(+), 80 deletions(-) create mode 100644 webui/src/features/nodes/NodeLeasesModal.tsx create mode 100644 webui/src/features/nodes/nodeFormat.ts diff --git a/internal/api/handler_node.go b/internal/api/handler_node.go index 53809b3c..bc02491b 100644 --- a/internal/api/handler_node.go +++ b/internal/api/handler_node.go @@ -42,6 +42,8 @@ func compareNodeSummaries(sortBy string, a, b service.NodeSummary) int { order = cmp.Compare(a.FailureCount, b.FailureCount) case "region": order = strings.Compare(a.Region, b.Region) + case "lease_count": + order = cmp.Compare(a.LeaseCount, b.LeaseCount) default: order = strings.Compare(nodeTagSortKey(a), nodeTagSortKey(b)) } @@ -152,7 +154,7 @@ func HandleListNodes(cp *service.ControlPlaneService) http.HandlerFunc { return } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"tag", "created_at", "failure_count", "region"}, "tag", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"tag", "created_at", "failure_count", "region", "lease_count"}, "tag", "asc") if !ok { return } @@ -211,3 +213,26 @@ func HandleProbeLatency(cp *service.ControlPlaneService) http.HandlerFunc { WriteJSON(w, http.StatusOK, result) } } + +// HandleListNodeLeases returns a handler for GET /api/v1/nodes/{hash}/leases. +// It returns every lease currently bound to the node; pass platform_id=... to +// scope the result to a single platform. +func HandleListNodeLeases(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + platformID, ok := parseOptionalUUIDQuery(w, r, "platform_id", "platform_id") + if !ok { + return + } + pid := "" + if platformID != nil { + pid = *platformID + } + leases, err := cp.ListLeasesByNode(hash, pid) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, leases) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 54fab0ab..c740f1e0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -112,6 +112,7 @@ func NewServerWithAddress( // Nodes. authed.Handle("GET /api/v1/nodes", HandleListNodes(cp)) authed.Handle("GET /api/v1/nodes/{hash}", HandleGetNode(cp)) + authed.Handle("GET /api/v1/nodes/{hash}/leases", HandleListNodeLeases(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-egress", HandleProbeEgress(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-latency", HandleProbeLatency(cp)) diff --git a/internal/routing/router.go b/internal/routing/router.go index 5f001886..7981d10d 100644 --- a/internal/routing/router.go +++ b/internal/routing/router.go @@ -606,6 +606,52 @@ func (r *Router) RangeLeases(platformID string, fn func(account string, lease Le return true } +// RangeAllLeases iterates over all leases across every platform. +// fn receives the owning platform ID alongside the account/lease pair. +// Returning false from fn stops the iteration early. +func (r *Router) RangeAllLeases(fn func(platformID, account string, lease Lease) bool) { + r.states.Range(func(platformID string, state *PlatformRoutingState) bool { + keepGoing := true + state.Leases.Range(func(account string, lease Lease) bool { + if !fn(platformID, account, lease) { + keepGoing = false + return false + } + return true + }) + return keepGoing + }) +} + +// SnapshotNodeLoad returns a best-effort point-in-time count of leases per +// node hash for a single platform. Empty map if the platform has no state. +func (r *Router) SnapshotNodeLoad(platformID string) map[node.Hash]int64 { + state, ok := r.states.Load(platformID) + if !ok { + return map[node.Hash]int64{} + } + out := make(map[node.Hash]int64) + state.Leases.Range(func(_ string, lease Lease) bool { + out[lease.NodeHash]++ + return true + }) + return out +} + +// SnapshotNodeLoadAll returns a best-effort point-in-time count of leases per +// node hash, aggregated across every platform. +func (r *Router) SnapshotNodeLoadAll() map[node.Hash]int64 { + out := make(map[node.Hash]int64) + r.states.Range(func(_ string, state *PlatformRoutingState) bool { + state.Leases.Range(func(_ string, lease Lease) bool { + out[lease.NodeHash]++ + return true + }) + return true + }) + return out +} + // DeleteLease removes a single lease by platform and account. // Returns true if a lease was deleted. Emits a LeaseRemove event. func (r *Router) DeleteLease(platformID, account string) bool { diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index 6f9f6e57..a93a4510 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -1,6 +1,7 @@ package service import ( + "sort" "strings" "time" @@ -211,6 +212,104 @@ type IPLoadEntry struct { LeaseCount int64 `json:"lease_count"` } +// NodeLeaseResponse is the API response for a lease scoped to a specific node. +// Unlike LeaseResponse, it carries the owning platform so the caller can render +// cross-platform lease bindings for a single node. +type NodeLeaseResponse struct { + PlatformID string `json:"platform_id"` + PlatformName string `json:"platform_name"` + Account string `json:"account"` + NodeHash string `json:"node_hash"` + EgressIP string `json:"egress_ip"` + CreatedAt string `json:"created_at"` + Expiry string `json:"expiry"` + LastAccessed string `json:"last_accessed"` +} + +// ListLeasesByNode returns every lease bound to the given node hash. +// When platformID is non-empty, only leases under that platform are returned; +// otherwise leases across all platforms are aggregated. +// Results are sorted by CreatedAtNs descending (newest first). +func (s *ControlPlaneService) ListLeasesByNode(nodeHashHex, platformID string) ([]NodeLeaseResponse, error) { + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + if _, ok := s.Pool.GetEntry(h); !ok { + return nil, notFound("node not found") + } + + type entry struct { + resp NodeLeaseResponse + createdAtNs int64 + } + platformNameCache := make(map[string]string) + resolvePlatformName := func(pid string) string { + if name, ok := platformNameCache[pid]; ok { + return name + } + name := "" + if plat, ok := s.Pool.GetPlatform(pid); ok { + name = plat.Name + } + platformNameCache[pid] = name + return name + } + + var entries []entry + addLease := func(pid, account string, lease routing.Lease) { + if lease.NodeHash != h { + return + } + entries = append(entries, entry{ + resp: NodeLeaseResponse{ + PlatformID: pid, + PlatformName: resolvePlatformName(pid), + Account: account, + NodeHash: lease.NodeHash.Hex(), + EgressIP: lease.EgressIP.String(), + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), + Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), + LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), + }, + createdAtNs: lease.CreatedAtNs, + }) + } + + platformID = strings.TrimSpace(platformID) + if platformID != "" { + if _, ok := s.Pool.GetPlatform(platformID); !ok { + return nil, notFound("platform not found") + } + s.Router.RangeLeases(platformID, func(account string, lease routing.Lease) bool { + addLease(platformID, account, lease) + return true + }) + } else { + s.Router.RangeAllLeases(func(pid, account string, lease routing.Lease) bool { + addLease(pid, account, lease) + return true + }) + } + + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].createdAtNs != entries[j].createdAtNs { + return entries[i].createdAtNs > entries[j].createdAtNs + } + if entries[i].resp.PlatformName != entries[j].resp.PlatformName { + return entries[i].resp.PlatformName < entries[j].resp.PlatformName + } + return entries[i].resp.Account < entries[j].resp.Account + }) + + result := make([]NodeLeaseResponse, 0, len(entries)) + for _, e := range entries { + result = append(result, e.resp) + } + return result, nil +} + // GetIPLoad returns IP load stats for a platform. func (s *ControlPlaneService) GetIPLoad(platformID string) ([]IPLoadEntry, error) { if _, ok := s.Pool.GetPlatform(platformID); !ok { diff --git a/internal/service/control_plane_nodes.go b/internal/service/control_plane_nodes.go index 56d7f91b..40ed48ca 100644 --- a/internal/service/control_plane_nodes.go +++ b/internal/service/control_plane_nodes.go @@ -115,6 +115,25 @@ func (s *ControlPlaneService) ListNodes(filters NodeFilters) ([]NodeSummary, err if result == nil { result = []NodeSummary{} } + + if s.Router != nil { + var leaseLoads map[node.Hash]int64 + if filters.PlatformID != nil { + leaseLoads = s.Router.SnapshotNodeLoad(*filters.PlatformID) + } else { + leaseLoads = s.Router.SnapshotNodeLoadAll() + } + if len(leaseLoads) > 0 { + for i := range result { + h, err := node.ParseHex(result[i].NodeHash) + if err != nil { + continue + } + result[i].LeaseCount = leaseLoads[h] + } + } + } + return result, nil } diff --git a/internal/service/control_plane_platform.go b/internal/service/control_plane_platform.go index c0f8701b..79b402a8 100644 --- a/internal/service/control_plane_platform.go +++ b/internal/service/control_plane_platform.go @@ -590,6 +590,7 @@ type NodeSummary struct { LastAuthorityLatencyProbeAttempt string `json:"last_authority_latency_probe_attempt,omitempty"` ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` LastEgressUpdateAttempt string `json:"last_egress_update_attempt,omitempty"` + LeaseCount int64 `json:"lease_count"` Tags []NodeTag `json:"tags"` } diff --git a/webui/src/features/nodes/NodeLeasesModal.tsx b/webui/src/features/nodes/NodeLeasesModal.tsx new file mode 100644 index 00000000..faa4767f --- /dev/null +++ b/webui/src/features/nodes/NodeLeasesModal.tsx @@ -0,0 +1,213 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createColumnHelper } from "@tanstack/react-table"; +import { AlertTriangle, Globe, Link2Off, Sparkles, Tag, X, Zap } from "lucide-react"; +import { useEffect } from "react"; +import { Button } from "../../components/ui/Button"; +import { Card } from "../../components/ui/Card"; +import { DataTable } from "../../components/ui/DataTable"; +import { useI18n } from "../../i18n"; +import { formatApiErrorMessage } from "../../lib/error-message"; +import { formatRelativeTime } from "../../lib/time"; +import { deletePlatformLease } from "../platforms/api"; +import { listNodeLeases } from "./api"; +import { + displayableReferenceLatencyMs, + firstTag, + formatLatency, + referenceLatencyColor, + regionToFlag, +} from "./nodeFormat"; +import type { NodeLease, NodeSummary } from "./types"; + +const columnHelper = createColumnHelper(); + +type Props = { + node: NodeSummary; + platformId?: string; + onClose: () => void; + showToast: (type: "success" | "error", message: string) => void; +}; + +export function NodeLeasesModal({ node, platformId, onClose, showToast }: Props) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + + const queryKey = ["node-leases", node.node_hash, platformId ?? ""]; + + const leasesQuery = useQuery({ + queryKey, + queryFn: () => listNodeLeases(node.node_hash, platformId), + refetchInterval: 15_000, + placeholderData: (prev) => prev, + }); + + const leases = leasesQuery.data ?? []; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const invalidateRelatedQueries = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["node-leases", node.node_hash] }), + queryClient.invalidateQueries({ queryKey: ["nodes"] }), + queryClient.invalidateQueries({ queryKey: ["platform-leases"] }), + queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }), + ]); + }; + + const unbindMutation = useMutation({ + mutationFn: (target: { platformId: string; account: string }) => + deletePlatformLease(target.platformId, target.account), + onSuccess: async (_, target) => { + await invalidateRelatedQueries(); + showToast("success", t("租约 {{account}} 已解绑", { account: target.account })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const handleUnbind = (lease: NodeLease) => { + const confirmed = window.confirm( + t("确认解绑租约 {{account}}(平台 {{platform}})?", { + account: lease.account, + platform: lease.platform_name || lease.platform_id, + }), + ); + if (!confirmed) { + return; + } + unbindMutation.mutate({ platformId: lease.platform_id, account: lease.account }); + }; + + const columns = [ + columnHelper.accessor("platform_name", { + header: () => t("平台"), + cell: (info) => info.getValue() || info.row.original.platform_id || "-", + }), + columnHelper.accessor("account", { + header: () => t("账号"), + cell: (info) => {info.getValue()}, + }), + columnHelper.accessor("created_at", { + header: () => t("绑定时间"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("expiry", { + header: () => t("到期时间"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("last_accessed", { + header: () => t("最近访问"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.display({ + id: "actions", + header: () => t("操作"), + cell: (info) => ( + + ), + }), + ]; + + const titleTag = firstTag(node); + const scopeHint = platformId + ? t("仅展示当前筛选平台下的租约") + : t("展示该节点在所有平台上的租约"); + const sourceLabel = titleTag; + const egressLabel = node.egress_ip + ? node.region + ? `${node.egress_ip} ${regionToFlag(node.region)}` + : node.egress_ip + : "-"; + const latencyMs = displayableReferenceLatencyMs(node); + + return ( +
+ event.stopPropagation()}> +
+
+

{t("节点 {{name}} 的租约", { name: titleTag })}

+

{scopeHint}

+
+ +
+ +
+ + + {sourceLabel} + + + + {egressLabel} + + + + + {latencyMs !== null ? formatLatency(latencyMs) : "-"} + + +
+ + {leasesQuery.data && leases.length ? ( +

{t("共 {{count}} 条", { count: leases.length })}

+ ) : null} + + {leasesQuery.isLoading ?

{t("正在加载租约数据...")}

: null} + + {leasesQuery.isError ? ( +
+ + {formatApiErrorMessage(leasesQuery.error, t)} +
+ ) : null} + + {!leasesQuery.isLoading && !leases.length ? ( +
+ +

{t("没有租约")}

+
+ ) : null} + + {leases.length ? ( + `${l.platform_id}:${l.account}`} + wrapClassName="node-leases-table-wrap" + /> + ) : null} +
+
+ ); +} diff --git a/webui/src/features/nodes/NodesPage.tsx b/webui/src/features/nodes/NodesPage.tsx index 86059996..780b4f4f 100644 --- a/webui/src/features/nodes/NodesPage.tsx +++ b/webui/src/features/nodes/NodesPage.tsx @@ -19,12 +19,20 @@ import { listPlatforms } from "../platforms/api"; import type { Platform } from "../platforms/types"; import { listSubscriptions } from "../subscriptions/api"; import { getNode, listNodes, probeEgress, probeLatency } from "./api"; +import { NodeLeasesModal } from "./NodeLeasesModal"; import type { NodeSummary } from "./types"; -import { getAllRegions, getRegionName } from "./regions"; +import { getAllRegions } from "./regions"; +import { + displayableReferenceLatencyMs, + firstTag, + formatLatency, + getNodeDisplayStatus, + referenceLatencyColor, + regionToFlag, +} from "./nodeFormat"; import type { NodeListFilters, NodeSortBy, SortOrder } from "./types"; type NodeStatusFilter = "all" | "healthy" | "circuit_open" | "error" | "disabled"; -type NodeDisplayStatus = "healthy" | "circuit_open" | "pending_test" | "error" | "disabled"; type ProbeAction = "egress" | "latency"; type NodeFilterDraft = { @@ -178,71 +186,6 @@ function draftToActiveFilters(draft: NodeFilterDraft): NodeListFilters { }; } -function firstTag(node: { display_tag?: string; tags: { tag: string }[] }): string { - if (node.display_tag && node.display_tag.trim()) { - return node.display_tag; - } - if (!node.tags.length) { - return "-"; - } - return node.tags[0].tag; -} - -function hasReferenceLatency(node: NodeSummary): node is NodeSummary & { reference_latency_ms: number } { - return typeof node.reference_latency_ms === "number"; -} - -function isPendingTestNode(node: NodeSummary): boolean { - return Boolean(node.circuit_open_since) && node.failure_count === 0; -} - -function getNodeDisplayStatus(node: NodeSummary): NodeDisplayStatus { - if (!node.enabled) { - return "disabled"; - } - if (!node.has_outbound) { - return "error"; - } - if (isPendingTestNode(node)) { - return "pending_test"; - } - if (node.circuit_open_since) { - return "circuit_open"; - } - return "healthy"; -} - -function referenceLatencyColor(latencyMs: number): string { - if (!Number.isFinite(latencyMs)) { - return "var(--text-secondary)"; - } - if (latencyMs <= 400) { - return "var(--success)"; - } - if (latencyMs <= 1000) { - return "var(--warning)"; - } - return "var(--danger)"; -} - -function displayableReferenceLatencyMs(node: NodeSummary): number | null { - if (getNodeDisplayStatus(node) !== "healthy") { - return null; - } - if (!hasReferenceLatency(node)) { - return null; - } - return node.reference_latency_ms; -} - - -function formatLatency(value: number): string { - if (!Number.isFinite(value)) { - return "-"; - } - return `${value.toFixed(0)} ms`; -} - function sortIndicator(active: boolean, order: SortOrder): string { if (!active) { return "↕"; @@ -250,16 +193,6 @@ function sortIndicator(active: boolean, order: SortOrder): string { return order === "asc" ? "▲" : "▼"; } -function regionToFlag(region: string | undefined): string { - if (!region || region.length !== 2) { - return region || "-"; - } - const code = region.toUpperCase(); - const flag = String.fromCodePoint(...[...code].map((c) => c.charCodeAt(0) + 127397)); - const name = getRegionName(code); - return name ? `${flag} ${code} (${name})` : `${flag} ${code}`; -} - export function NodesPage() { const { locale, t } = useI18n(); const location = useLocation(); @@ -273,6 +206,7 @@ export function NodesPage() { const [pageSize, setPageSize] = useState(200); const [selectedNodeHash, setSelectedNodeHash] = useState(""); const [drawerOpen, setDrawerOpen] = useState(false); + const [leasesModalNodeHash, setLeasesModalNodeHash] = useState(null); const [pendingEgressHashes, setPendingEgressHashes] = useState>(() => new Set()); const [pendingLatencyHashes, setPendingLatencyHashes] = useState>(() => new Set()); const { toasts, showToast, dismissToast } = useToast(); @@ -608,6 +542,32 @@ export function NodesPage() { return {t("健康")}; }, }), + col.accessor("lease_count", { + header: () => ( + + ), + cell: (info) => { + const node = info.row.original; + const count = node.lease_count ?? 0; + return ( + + ); + }, + }), col.accessor("created_at", { header: () => ( + {node.manually_disabled ? ( + + ) : ( + + )} ); }, @@ -746,6 +838,7 @@ export function NodesPage() { + @@ -849,7 +942,9 @@ export function NodesPage() { const status = getNodeDisplayStatus(detailNode); return (
- {status === "error" ? ( + {status === "manually_disabled" ? ( + {t("手动禁用")} + ) : status === "error" ? ( {t("错误")} ) : status === "disabled" ? ( {t("禁用")} diff --git a/webui/src/features/nodes/api.ts b/webui/src/features/nodes/api.ts index 7a89b360..a02b05d3 100644 --- a/webui/src/features/nodes/api.ts +++ b/webui/src/features/nodes/api.ts @@ -13,6 +13,7 @@ const basePath = "/api/v1/nodes"; type ApiNodeSummary = Omit & { tags?: NodeSummary["tags"] | null; enabled?: boolean | null; + manually_disabled?: boolean | null; display_tag?: string | null; last_error?: string | null; circuit_open_since?: string | null; @@ -31,6 +32,7 @@ function normalizeNode(raw: ApiNodeSummary): NodeSummary { const normalized: NodeSummary = { ...rest, enabled: raw.enabled !== false, + manually_disabled: Boolean(raw.manually_disabled), display_tag: raw.display_tag || "", tags: Array.isArray(raw.tags) ? raw.tags : [], last_error: raw.last_error || "", @@ -87,6 +89,9 @@ export async function listNodes(filters: NodeListQuery): Promise>(`${basePath}?${query.toString()}`); return { @@ -123,3 +128,19 @@ export async function listNodeLeases(hash: string, platformId?: string): Promise const data = await apiRequest(path); return Array.isArray(data) ? data : []; } + +export type DisableNodeResult = { + released_lease_count: number; +}; + +export async function disableNode(hash: string): Promise { + return apiRequest(`${basePath}/${hash}/actions/disable`, { + method: "POST", + }); +} + +export async function enableNode(hash: string): Promise { + return apiRequest(`${basePath}/${hash}/actions/enable`, { + method: "POST", + }); +} diff --git a/webui/src/features/nodes/nodeFormat.ts b/webui/src/features/nodes/nodeFormat.ts index 2b8138a2..3c8bd8d2 100644 --- a/webui/src/features/nodes/nodeFormat.ts +++ b/webui/src/features/nodes/nodeFormat.ts @@ -1,7 +1,13 @@ import type { NodeSummary } from "./types"; import { getRegionName } from "./regions"; -export type NodeDisplayStatus = "healthy" | "circuit_open" | "pending_test" | "error" | "disabled"; +export type NodeDisplayStatus = + | "healthy" + | "circuit_open" + | "pending_test" + | "error" + | "disabled" + | "manually_disabled"; export function firstTag(node: { display_tag?: string; tags: { tag: string }[] }): string { if (node.display_tag && node.display_tag.trim()) { @@ -24,6 +30,9 @@ export function isPendingTestNode(node: NodeSummary): boolean { } export function getNodeDisplayStatus(node: NodeSummary): NodeDisplayStatus { + if (node.manually_disabled) { + return "manually_disabled"; + } if (!node.enabled) { return "disabled"; } diff --git a/webui/src/features/nodes/types.ts b/webui/src/features/nodes/types.ts index e7ea194f..c107c5f2 100644 --- a/webui/src/features/nodes/types.ts +++ b/webui/src/features/nodes/types.ts @@ -8,6 +8,7 @@ export type NodeSummary = { node_hash: string; created_at: string; enabled: boolean; + manually_disabled?: boolean; display_tag?: string; has_outbound: boolean; last_error?: string; @@ -44,6 +45,7 @@ export type NodeListFilters = { egress_ip?: string; probed_since?: string; enabled?: boolean; + manually_disabled?: boolean; circuit_open?: boolean; has_outbound?: boolean; }; From 9a915f12b8286db72833bc26b04731987415c289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sun, 14 Jun 2026 14:54:39 +0800 Subject: [PATCH 11/25] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=8D=E5=AF=86?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E6=AE=B5=E6=A8=A1=E5=BC=8F=EF=BC=88Free-Mode?= =?UTF-8?q?=20Ports=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在主端口之外额外监听一段端口供免密使用:整段端口绑定一个平台, 每个端口通过粘性租约固定一个出口节点/IP,访问安全由内网/白名单模式控制。 改动列表: - 配置: env.go 新增 RESIN_FREE_PORT_*(起始端口/数量/平台/访问模式/白名单)及校验 - IP 访问控制: 新增 netutil/access_control.go,支持内网与白名单两种模式 - 免密身份注入: forward.go/socks5.go 支持固定平台+端口派生 account(新增 free_port.go),跳过 Token 认证 - 连接闸门: inbound_demux.go 新增可选 connGate,免密端口注入 IP 访问控制 - 平台自动创建: main.go 新增 ensureFreePortPlatform,绑定平台不存在时自动创建(备注免密专用) - 监听装配: app_runtime.go 装配免密端口监听器(共享 handler)与优雅关闭 - 文档与部署: docker-compose/Dockerfile/README 同步免密端口配置 - 测试: 新增 access_control/free_port/env 免密配置单元测试 --- .github/Dockerfile.release | 2 +- Dockerfile | 2 +- README.md | 33 +++++++ README.zh-CN.md | 33 +++++++ cmd/resin/app_runtime.go | 97 +++++++++++++++++++ cmd/resin/inbound_demux.go | 8 +- cmd/resin/main.go | 40 ++++++++ docker-compose.yml.example | 13 +++ internal/config/env.go | 58 ++++++++++++ internal/config/env_test.go | 97 +++++++++++++++++++ internal/netutil/access_control.go | 119 ++++++++++++++++++++++++ internal/netutil/access_control_test.go | 86 +++++++++++++++++ internal/proxy/forward.go | 31 +++++- internal/proxy/free_port.go | 44 +++++++++ internal/proxy/free_port_test.go | 21 +++++ internal/proxy/socks5.go | 35 ++++++- 16 files changed, 711 insertions(+), 8 deletions(-) create mode 100644 internal/netutil/access_control.go create mode 100644 internal/netutil/access_control_test.go create mode 100644 internal/proxy/free_port.go create mode 100644 internal/proxy/free_port_test.go diff --git a/.github/Dockerfile.release b/.github/Dockerfile.release index ee79c791..cca272b7 100644 --- a/.github/Dockerfile.release +++ b/.github/Dockerfile.release @@ -16,7 +16,7 @@ COPY release-bin/${TARGETOS}/${TARGETARCH}/resin-${TARGETOS}-${TARGETARCH} /usr/ COPY docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh -EXPOSE 2260 +EXPOSE 2260 21000-22000 VOLUME ["/var/cache/resin", "/var/lib/resin", "/var/log/resin"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/Dockerfile b/Dockerfile index 27b72b5b..df94cefd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ COPY --from=go-builder /out/resin /usr/local/bin/resin COPY docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh -EXPOSE 2260 +EXPOSE 2260 21000-22000 VOLUME ["/var/cache/resin", "/var/lib/resin", "/var/log/resin"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index ab139e59..a9906a3c 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,39 @@ For reverse proxy, include Platform in the URL prefix: curl http://127.0.0.1:2260/my-token/MyPlatform/https/api.ipify.org ``` +## 🔓 Free-Mode Ports (Password-less) + +If you want to use the proxy **without a password** and have “**one local port = one fixed egress IP**”, enable free-mode ports. + +Beyond the main port, Resin listens on a range of consecutive ports (starting at `21000` by default). The whole range is bound to a single platform (`MM` by default, **auto-created** and labeled as free-mode-only if missing); **each port** pins its own egress node/IP via a sticky lease — the same port keeps a stable egress, while different ports map to different egresses. Free-mode ports serve **both HTTP forward proxy and SOCKS5**, and expose **no** admin UI / reverse proxy. + +### Enabling (environment variables) + +| Variable | Description | Default | +| --- | --- | --- | +| `RESIN_FREE_PORT_START` | Start port; `0`/unset disables the feature | `0` | +| `RESIN_FREE_PORT_COUNT` | Number of ports to open (max 256) | `0` | +| `RESIN_FREE_PORT_PLATFORM` | Platform bound to the whole range (auto-created if missing) | empty | +| `RESIN_FREE_PORT_ACCESS_MODE` | Access control: `intranet` / `whitelist` | `intranet` | +| `RESIN_FREE_PORT_WHITELIST` | IP/CIDR list (JSON array) for whitelist mode | `[]` | + +> ⚠️ Security: free-mode ports rely entirely on access control. `intranet` (default) allows only private/loopback/link-local clients; `whitelist` allows only listed IP/CIDRs. **Never expose free-mode ports to the public internet with an empty or overly broad whitelist.** + +### Examples + +```bash +# HTTP forward proxy on port 21000, no auth required +curl -x http://127.0.0.1:21000 https://api.ipify.org + +# SOCKS5 on the same port +curl --proxy socks5h://127.0.0.1:21000 https://api.ipify.org + +# Different port = different fixed egress +curl -x http://127.0.0.1:21001 https://api.ipify.org +``` + +> For Docker, `docker-compose.yml.example` maps `21000-22000` by default. Note: mapping the whole span spawns ~1000 docker-proxy processes; in production, narrow the mapping to your actual count or use `network_mode: host`. + ## 📖 Advanced Usage: Sticky Session Proxy When your business depends on IP continuity or long-lived interactions, use Resin's core feature: **sticky proxying**. diff --git a/README.zh-CN.md b/README.zh-CN.md index c180132a..06a7052e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -169,6 +169,39 @@ curl --proxy socks5h://127.0.0.1:2260 \ curl http://127.0.0.1:2260/my-token/MyPlatform/https/api.ipify.org ``` +## 🔓 免密端口段(Free-Mode Ports) + +如果你希望在**不填密码**的前提下使用代理,并且让“**一个本地端口 = 一个固定出口 IP**”,可以开启免密端口段。 + +它会在主端口之外额外监听一段连续端口(默认从 `21000` 起)。整段端口固定绑定到一个平台(默认 `MM`,不存在时**自动创建**,标注为“免密专用”);段内**每个端口**通过粘性租约稳定绑定该平台下的一个出口节点/IP——同一端口出口保持稳定,不同端口对应不同出口。免密端口同时支持 **HTTP 正向代理与 SOCKS5**,且**不暴露**管理后台 / 反向代理。 + +### 开启方式(环境变量) + +| 变量 | 说明 | 默认 | +| --- | --- | --- | +| `RESIN_FREE_PORT_START` | 起始端口;`0` 或不设表示关闭该功能 | `0` | +| `RESIN_FREE_PORT_COUNT` | 监听端口数量(上限 256) | `0` | +| `RESIN_FREE_PORT_PLATFORM` | 整段绑定的平台名(不存在则自动创建) | 空 | +| `RESIN_FREE_PORT_ACCESS_MODE` | 访问控制:`intranet`(内网)/ `whitelist`(白名单) | `intranet` | +| `RESIN_FREE_PORT_WHITELIST` | 白名单模式下的 IP/CIDR 列表(JSON 数组) | `[]` | + +> ⚠️ 安全:免密端口的安全完全由访问控制兜底。默认 `intranet` 仅允许内网/回环/链路本地地址访问;`whitelist` 仅允许指定 IP/CIDR。**切勿在公网暴露白名单为空或范围过宽的免密端口。** + +### 使用示例 + +```bash +# HTTP 正向代理:端口 21000,无需任何认证 +curl -x http://127.0.0.1:21000 https://api.ipify.org + +# SOCKS5:同一端口同时支持 +curl --proxy socks5h://127.0.0.1:21000 https://api.ipify.org + +# 不同端口 = 不同固定出口 +curl -x http://127.0.0.1:21001 https://api.ipify.org +``` + +> Docker 部署时,`docker-compose.yml.example` 已默认映射 `21000-22000`。注意:映射整段会拉起约 1000 个 docker-proxy 进程,生产建议把映射收窄到实际数量,或使用 `network_mode: host`。 + ## 📖 进阶使用:粘性代理 当业务遇到**对 IP 变化敏感**的服务,或者需要持续交互时,你需要使用 Resin 的核心特性:**粘性代理**。 diff --git a/cmd/resin/app_runtime.go b/cmd/resin/app_runtime.go index dda16e98..191f60c0 100644 --- a/cmd/resin/app_runtime.go +++ b/cmd/resin/app_runtime.go @@ -46,6 +46,9 @@ type resinApp struct { } inboundLn net.Listener transportPool *proxy.OutboundTransportPool + + freePortServers []*inboundDemuxServer + freePortListeners []net.Listener } func run() error { @@ -455,6 +458,84 @@ func (a *resinApp) buildNetworkServers(engine *state.StateEngine) error { a.inboundLn = proxy.NewCountingListener(inboundLn, a.metricsManager) a.inboundSrv = newInboundDemuxServer(&http.Server{Handler: inboundHandler}, socks5Inbound) + if a.envCfg.FreePortStart > 0 { + if err := a.buildFreePortServers(proxyEvents, outboundTransportCfg); err != nil { + return err + } + } + + return nil +} + +// buildFreePortServers opens the password-less port range. One shared pair of +// handlers serves the whole range; each connection's account is derived from +// its local port, so every port pins its own sticky lease (its own egress) +// within the bound platform. Free-mode forces a V1 SOCKS5 handshake with an +// empty token (so NO_AUTH is accepted) regardless of the global auth mode, and +// the HTTP side mounts only the forward proxy — no control plane / reverse +// proxy is exposed on these ports. +func (a *resinApp) buildFreePortServers( + proxyEvents proxy.ConfigAwareEventEmitter, + outboundTransportCfg proxy.OutboundTransportConfig, +) error { + platformName := a.envCfg.FreePortPlatform + if _, ok := a.topoRuntime.pool.GetPlatformByName(platformName); !ok { + // Should not happen: ensureFreePortPlatform creates it during bootstrap. + return fmt.Errorf("free-port platform %q not found", platformName) + } + + accessController, err := netutil.NewAccessController( + a.envCfg.FreePortAccessMode, + a.envCfg.FreePortWhitelist, + ) + if err != nil { + return fmt.Errorf("free-port access controller: %w", err) + } + + freeForward := proxy.NewForwardProxy(proxy.ForwardProxyConfig{ + ProxyToken: "", + AuthVersion: string(config.AuthVersionV1), + Router: a.topoRuntime.router, + Pool: a.topoRuntime.pool, + Health: a.topoRuntime.pool, + Events: proxyEvents, + MetricsSink: a.metricsManager, + OutboundTransport: outboundTransportCfg, + TransportPool: a.transportPool, + ForcedPlatform: platformName, + AccountFromLocalPort: true, + }) + freeSocks5 := proxy.NewSocks5Inbound(proxy.Socks5InboundConfig{ + ProxyToken: "", + AuthVersion: string(config.AuthVersionV1), + Router: a.topoRuntime.router, + Pool: a.topoRuntime.pool, + Health: a.topoRuntime.pool, + Events: proxyEvents, + MetricsSink: a.metricsManager, + ForcedPlatform: platformName, + AccountFromLocalPort: true, + }) + + start := a.envCfg.FreePortStart + end := start + a.envCfg.FreePortCount - 1 + opened := 0 + for port := start; port <= end; port++ { + ln, lnErr := net.Listen("tcp", formatListenAddress(a.envCfg.ListenAddress, port)) + if lnErr != nil { + log.Printf("Free-port %d listen failed, skipping: %v", port, lnErr) + continue + } + srv := newInboundDemuxServer(&http.Server{Handler: freeForward}, freeSocks5) + srv.connGate = func(c net.Conn) bool { return accessController.Allow(c.RemoteAddr()) } + a.freePortServers = append(a.freePortServers, srv) + a.freePortListeners = append(a.freePortListeners, proxy.NewCountingListener(ln, a.metricsManager)) + opened++ + } + log.Printf( + "Free-mode ports: %d/%d opened on [%d-%d] -> platform %q (access=%s)", + opened, a.envCfg.FreePortCount, start, end, platformName, a.envCfg.FreePortAccessMode, + ) return nil } @@ -506,6 +587,14 @@ func (a *resinApp) startServers() <-chan error { reportServerErr("resin server", a.inboundSrv.Serve(a.inboundLn)) }() + for i := range a.freePortServers { + srv := a.freePortServers[i] + ln := a.freePortListeners[i] + go func() { + reportServerErr("free-port server", srv.Serve(ln)) + }() + } + return serverErrCh } @@ -536,6 +625,14 @@ func (a *resinApp) shutdown(ctx context.Context) { if err := a.inboundSrv.Shutdown(ctx); err != nil { log.Printf("Server shutdown error: %v", err) } + for _, srv := range a.freePortServers { + if err := srv.Shutdown(ctx); err != nil { + log.Printf("Free-port server shutdown error: %v", err) + } + } + if len(a.freePortServers) > 0 { + log.Printf("Free-mode ports stopped (%d)", len(a.freePortServers)) + } log.Println("Resin server stopped") if a.transportPool != nil { a.transportPool.CloseAll() diff --git a/cmd/resin/inbound_demux.go b/cmd/resin/inbound_demux.go index 5aaaf8c7..efa0024a 100644 --- a/cmd/resin/inbound_demux.go +++ b/cmd/resin/inbound_demux.go @@ -28,6 +28,7 @@ type inboundDemuxServer struct { httpServer *http.Server httpListener *connChannelListener socksHandler inboundConnHandler + connGate func(net.Conn) bool mu sync.Mutex outer net.Listener @@ -89,7 +90,6 @@ func (s *inboundDemuxServer) Serve(ln net.Listener) error { } go s.handleAcceptedConn(conn) } - return nil } func inboundDemuxAcceptRetryDelay(err error, prev time.Duration) (time.Duration, bool) { @@ -160,6 +160,12 @@ func (s *inboundDemuxServer) Shutdown(ctx context.Context) error { func (s *inboundDemuxServer) handleAcceptedConn(conn net.Conn) { defer s.workerWG.Done() + // Optional access gate (free-mode ports inject an IP allow-list / intranet + // check here; the main port leaves it nil so behavior is unchanged). + if s.connGate != nil && !s.connGate(conn) { + _ = conn.Close() + return + } s.trackActiveConn(conn) s.trackSniffConn(conn) diff --git a/cmd/resin/main.go b/cmd/resin/main.go index 5d40902b..be6f65fe 100644 --- a/cmd/resin/main.go +++ b/cmd/resin/main.go @@ -29,6 +29,7 @@ import ( "github.com/Resinat/Resin/internal/state" "github.com/Resinat/Resin/internal/subscription" "github.com/Resinat/Resin/internal/topology" + "github.com/google/uuid" ) type topologyRuntime struct { @@ -411,6 +412,9 @@ func bootstrapTopology( if err := ensureDefaultPlatform(engine, envCfg, dbPlats); err != nil { return fmt.Errorf("ensure default platform: %w", err) } + if err := ensureFreePortPlatform(engine, envCfg, dbPlats); err != nil { + return fmt.Errorf("ensure free-port platform: %w", err) + } dbPlats, err = engine.ListPlatforms() if err != nil { return fmt.Errorf("reload platforms: %w", err) @@ -479,6 +483,42 @@ func ensureDefaultPlatform( return nil } +// ensureFreePortPlatform auto-creates the platform bound to the free-mode ports +// when it does not already exist, mirroring ensureDefaultPlatform. The feature +// is disabled when RESIN_FREE_PORT_START is 0/unset. +func ensureFreePortPlatform( + engine *state.StateEngine, + envCfg *config.EnvConfig, + platformsInDB []model.Platform, +) error { + if envCfg == nil || envCfg.FreePortStart == 0 || envCfg.FreePortPlatform == "" { + return nil + } + for _, p := range platformsInDB { + if p.Name == envCfg.FreePortPlatform { + return nil // already exists; reuse it + } + } + + freePlatform := model.Platform{ + ID: uuid.NewString(), + Name: envCfg.FreePortPlatform, + StickyTTLNs: int64(envCfg.DefaultPlatformStickyTTL), + RegexFilters: append([]string(nil), envCfg.DefaultPlatformRegexFilters...), + RegionFilters: append([]string(nil), envCfg.DefaultPlatformRegionFilters...), + ReverseProxyMissAction: envCfg.DefaultPlatformReverseProxyMissAction, + ReverseProxyEmptyAccountBehavior: envCfg.DefaultPlatformReverseProxyEmptyAccountBehavior, + ReverseProxyFixedAccountHeader: envCfg.DefaultPlatformReverseProxyFixedAccountHeader, + AllocationPolicy: envCfg.DefaultPlatformAllocationPolicy, + UpdatedAtNs: time.Now().UnixNano(), + } + if err := engine.UpsertPlatform(freePlatform); err != nil { + return err + } + log.Printf("Created free-port platform %q (免密专用)", envCfg.FreePortPlatform) + return nil +} + var defaultFallbackAccountHeaders = []string{"Authorization", "x-api-key"} func ensureDefaultAccountHeaderRule(engine *state.StateEngine) error { diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 9736194f..8695fea5 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -12,9 +12,22 @@ services: RESIN_PROXY_TOKEN: ${RESIN_PROXY_TOKEN} RESIN_LISTEN_ADDRESS: 0.0.0.0 RESIN_PORT: 2260 + # --- Free-mode (password-less) ports --- + # Opens RESIN_FREE_PORT_COUNT consecutive ports from RESIN_FREE_PORT_START, + # all bound to platform RESIN_FREE_PORT_PLATFORM (auto-created if missing). + # Each port pins its own sticky egress IP. Default access mode is intranet. + RESIN_FREE_PORT_START: 21000 + RESIN_FREE_PORT_COUNT: 50 + RESIN_FREE_PORT_PLATFORM: MM + RESIN_FREE_PORT_ACCESS_MODE: intranet + # RESIN_FREE_PORT_WHITELIST: '["10.0.0.0/8","192.168.0.0/16"]' # whitelist mode only # TZ: ports: - "2260:2260" + # Free-mode range. NOTE: mapping the whole span spawns ~1000 docker-proxy + # processes (slow start, more memory). For production, narrow this to your + # actual RESIN_FREE_PORT_COUNT, or use `network_mode: host` to bypass it. + - "21000-22000:21000-22000" volumes: - resin_cache:/var/cache/resin - resin_state:/var/lib/resin diff --git a/internal/config/env.go b/internal/config/env.go index b53f358a..456d5c65 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Resinat/Resin/internal/netutil" "github.com/Resinat/Resin/internal/platform" "github.com/robfig/cron/v3" ) @@ -28,6 +29,13 @@ type EnvConfig struct { ResinPort int APIMaxBodyBytes int + // Free-mode (password-less) ports + FreePortStart int + FreePortCount int + FreePortPlatform string + FreePortAccessMode string + FreePortWhitelist []string + // Core MaxLatencyTableEntries int ProbeConcurrency int @@ -85,6 +93,13 @@ func LoadEnvConfig() (*EnvConfig, error) { cfg.ResinPort = envInt("RESIN_PORT", 2260, &errs) cfg.APIMaxBodyBytes = envInt("RESIN_API_MAX_BODY_BYTES", 1<<20, &errs) + // --- Free-mode ports (disabled when RESIN_FREE_PORT_START is 0/unset) --- + cfg.FreePortStart = envInt("RESIN_FREE_PORT_START", 0, &errs) + cfg.FreePortCount = envInt("RESIN_FREE_PORT_COUNT", 0, &errs) + cfg.FreePortPlatform = strings.TrimSpace(envStr("RESIN_FREE_PORT_PLATFORM", "")) + cfg.FreePortAccessMode = strings.TrimSpace(envStr("RESIN_FREE_PORT_ACCESS_MODE", netutil.AccessModeIntranet)) + cfg.FreePortWhitelist = envStringSlice("RESIN_FREE_PORT_WHITELIST", []string{}, &errs) + // --- Core --- cfg.MaxLatencyTableEntries = envInt("RESIN_MAX_LATENCY_TABLE_ENTRIES", 12, &errs) cfg.ProbeConcurrency = envInt("RESIN_PROBE_CONCURRENCY", 1000, &errs) @@ -205,6 +220,7 @@ func LoadEnvConfig() (*EnvConfig, error) { validatePort("RESIN_PORT", cfg.ResinPort, &errs) validatePositive("RESIN_API_MAX_BODY_BYTES", cfg.APIMaxBodyBytes, &errs) + validateFreePortConfig(cfg, &errs) validatePositive("RESIN_MAX_LATENCY_TABLE_ENTRIES", cfg.MaxLatencyTableEntries, &errs) if cfg.MaxLatencyTableEntries > 32 { @@ -387,6 +403,48 @@ func validatePositive(name string, value int, errs *[]string) { } } +// freePortMaxCount caps how many password-less ports may be opened at once, +// keeping fd/goroutine usage bounded (DESIGN: "按需指定数量"). +const freePortMaxCount = 256 + +// validateFreePortConfig validates free-mode port settings. The feature is +// disabled (and all checks skipped) when RESIN_FREE_PORT_START is 0/unset. +func validateFreePortConfig(cfg *EnvConfig, errs *[]string) { + if cfg.FreePortStart == 0 { + return + } + validatePort("RESIN_FREE_PORT_START", cfg.FreePortStart, errs) + if cfg.FreePortCount <= 0 { + *errs = append(*errs, "RESIN_FREE_PORT_COUNT must be positive when RESIN_FREE_PORT_START is set") + } else if cfg.FreePortCount > freePortMaxCount { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT_COUNT must be <= %d", freePortMaxCount)) + } + if cfg.FreePortCount > 0 { + end := cfg.FreePortStart + cfg.FreePortCount - 1 + if end > 65535 { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT range end %d exceeds 65535", end)) + } + if cfg.ResinPort >= cfg.FreePortStart && cfg.ResinPort <= end { + *errs = append(*errs, fmt.Sprintf( + "RESIN_PORT %d must not fall within free-port range [%d, %d]", + cfg.ResinPort, cfg.FreePortStart, end, + )) + } + } + if cfg.FreePortPlatform == "" { + *errs = append(*errs, "RESIN_FREE_PORT_PLATFORM must not be empty when RESIN_FREE_PORT_START is set") + } else if cfg.AuthVersion == AuthVersionV1 { + if err := platform.ValidatePlatformName(cfg.FreePortPlatform); err != nil { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT_PLATFORM: %v", err)) + } + } + // Reuse AccessController construction as the single source of truth for + // access-mode / whitelist validation. + if _, err := netutil.NewAccessController(cfg.FreePortAccessMode, cfg.FreePortWhitelist); err != nil { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT_ACCESS_MODE/RESIN_FREE_PORT_WHITELIST: %v", err)) + } +} + const ( v1ProxyTokenForbiddenChars = ".:|/\\@?#%~" v1ProxyTokenForbiddenSpacing = " \t\r\n" diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 71492437..eb2c5a87 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -551,6 +551,103 @@ func TestLoadEnvConfig_InvalidProxyTransportSettings(t *testing.T) { assertContains(t, err.Error(), "RESIN_PROXY_TRANSPORT_MAX_IDLE_CONNS_PER_HOST") } +func TestLoadEnvConfig_FreePortDisabledByDefault(t *testing.T) { + setEnvs(t, requiredEnvs()) + cfg, err := LoadEnvConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "FreePortStart", cfg.FreePortStart, 0) +} + +func TestLoadEnvConfig_FreePortEnabled(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "50" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + setEnvs(t, envs) + + cfg, err := LoadEnvConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "FreePortStart", cfg.FreePortStart, 21000) + assertEqual(t, "FreePortCount", cfg.FreePortCount, 50) + assertEqual(t, "FreePortPlatform", cfg.FreePortPlatform, "MM") + assertEqual(t, "FreePortAccessMode", cfg.FreePortAccessMode, "intranet") +} + +func TestLoadEnvConfig_FreePortMissingCount(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error when free-port count missing") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT_COUNT") +} + +func TestLoadEnvConfig_FreePortMissingPlatform(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "10" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error when free-port platform missing") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT_PLATFORM") +} + +func TestLoadEnvConfig_FreePortOverlapsMainPort(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_PORT"] = "21010" + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "50" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error when main port falls within free-port range") + } + assertContains(t, err.Error(), "RESIN_PORT") +} + +func TestLoadEnvConfig_FreePortWhitelistRequired(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "10" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + envs["RESIN_FREE_PORT_ACCESS_MODE"] = "whitelist" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error for whitelist mode with empty whitelist") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT") +} + +func TestLoadEnvConfig_FreePortInvalidPlatformNameV1(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_AUTH_VERSION"] = "V1" + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "10" + envs["RESIN_FREE_PORT_PLATFORM"] = "Bad.Name" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error for invalid V1 free-port platform name") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT_PLATFORM") +} + // --- test helpers --- func assertEqual[T comparable](t *testing.T, name string, got, want T) { diff --git a/internal/netutil/access_control.go b/internal/netutil/access_control.go new file mode 100644 index 00000000..6657eaa3 --- /dev/null +++ b/internal/netutil/access_control.go @@ -0,0 +1,119 @@ +package netutil + +import ( + "fmt" + "net" + "net/netip" + "strings" +) + +// Access modes for free-mode (password-less) ports. +const ( + // AccessModeIntranet allows only private / loopback / link-local clients. + AccessModeIntranet = "intranet" + // AccessModeWhitelist allows only clients matching a configured IP/CIDR list. + AccessModeWhitelist = "whitelist" +) + +// AccessController decides whether a remote address may use a free-mode port. +// +// It is the single source of truth for both config validation (constructing it +// validates the inputs) and the runtime connection gate. +type AccessController struct { + mode string + prefixes []netip.Prefix +} + +// NewAccessController builds an AccessController and validates its inputs. +// - mode must be AccessModeIntranet or AccessModeWhitelist. +// - In whitelist mode the list must be non-empty and every entry must be a +// valid IP address or CIDR prefix. +func NewAccessController(mode string, whitelist []string) (*AccessController, error) { + switch mode { + case AccessModeIntranet: + return &AccessController{mode: mode}, nil + case AccessModeWhitelist: + if len(whitelist) == 0 { + return nil, fmt.Errorf("whitelist must be non-empty in %q mode", AccessModeWhitelist) + } + prefixes := make([]netip.Prefix, 0, len(whitelist)) + for _, raw := range whitelist { + entry := strings.TrimSpace(raw) + if entry == "" { + return nil, fmt.Errorf("whitelist entry must not be empty") + } + prefix, err := parsePrefixOrAddr(entry) + if err != nil { + return nil, fmt.Errorf("invalid IP/CIDR %q: %w", entry, err) + } + prefixes = append(prefixes, prefix) + } + return &AccessController{mode: mode, prefixes: prefixes}, nil + default: + return nil, fmt.Errorf( + "invalid access mode %q (allowed: %s, %s)", + mode, AccessModeIntranet, AccessModeWhitelist, + ) + } +} + +// Allow reports whether the remote address may use the port. +// A nil controller or unparseable address denies by default (fail-closed). +func (a *AccessController) Allow(remote net.Addr) bool { + if a == nil { + return false + } + addr, ok := addrToNetip(remote) + if !ok { + return false + } + addr = addr.Unmap() + switch a.mode { + case AccessModeIntranet: + return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() + case AccessModeWhitelist: + for _, prefix := range a.prefixes { + if prefix.Contains(addr) { + return true + } + } + return false + default: + return false + } +} + +func parsePrefixOrAddr(entry string) (netip.Prefix, error) { + if strings.Contains(entry, "/") { + prefix, err := netip.ParsePrefix(entry) + if err != nil { + return netip.Prefix{}, err + } + return prefix.Masked(), nil + } + addr, err := netip.ParseAddr(entry) + if err != nil { + return netip.Prefix{}, err + } + return netip.PrefixFrom(addr, addr.BitLen()), nil +} + +func addrToNetip(remote net.Addr) (netip.Addr, bool) { + if remote == nil { + return netip.Addr{}, false + } + if tcp, ok := remote.(*net.TCPAddr); ok { + if a, ok := netip.AddrFromSlice(tcp.IP); ok { + return a, true + } + } + host, _, err := net.SplitHostPort(remote.String()) + if err != nil { + host = remote.String() + } + a, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + return a, true +} diff --git a/internal/netutil/access_control_test.go b/internal/netutil/access_control_test.go new file mode 100644 index 00000000..5a3b05ba --- /dev/null +++ b/internal/netutil/access_control_test.go @@ -0,0 +1,86 @@ +package netutil + +import ( + "net" + "testing" +) + +func mustTCPAddr(t *testing.T, hostport string) *net.TCPAddr { + t.Helper() + addr, err := net.ResolveTCPAddr("tcp", hostport) + if err != nil { + t.Fatalf("resolve %q: %v", hostport, err) + } + return addr +} + +func TestNewAccessController_Errors(t *testing.T) { + if _, err := NewAccessController("nope", nil); err == nil { + t.Error("expected error for invalid mode") + } + if _, err := NewAccessController(AccessModeWhitelist, nil); err == nil { + t.Error("expected error for empty whitelist") + } + if _, err := NewAccessController(AccessModeWhitelist, []string{"not-an-ip"}); err == nil { + t.Error("expected error for invalid whitelist entry") + } + if _, err := NewAccessController(AccessModeWhitelist, []string{"203.0.113.0/24"}); err != nil { + t.Errorf("unexpected error for valid whitelist: %v", err) + } + if _, err := NewAccessController(AccessModeIntranet, nil); err != nil { + t.Errorf("unexpected error for intranet mode: %v", err) + } +} + +func TestAccessController_Intranet(t *testing.T) { + ac, err := NewAccessController(AccessModeIntranet, nil) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + cases := map[string]bool{ + "10.0.0.5:1234": true, + "172.16.3.4:1": true, + "192.168.1.1:80": true, + "127.0.0.1:9": true, + "[::1]:9": true, + "169.254.1.1:9": true, // link-local + "8.8.8.8:53": false, // public + "[2001:4860:4860::8888]:53": false, + } + for hp, want := range cases { + if got := ac.Allow(mustTCPAddr(t, hp)); got != want { + t.Errorf("Allow(%s) = %v, want %v", hp, got, want) + } + } +} + +func TestAccessController_Whitelist(t *testing.T) { + ac, err := NewAccessController(AccessModeWhitelist, []string{"203.0.113.0/24", "8.8.8.8", "fd00::/8"}) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + cases := map[string]bool{ + "203.0.113.7:1": true, + "8.8.8.8:1": true, + "8.8.4.4:1": false, + "10.0.0.1:1": false, + "[fd00::1]:1": true, + "[fe80::1]:1": false, + } + for hp, want := range cases { + if got := ac.Allow(mustTCPAddr(t, hp)); got != want { + t.Errorf("Allow(%s) = %v, want %v", hp, got, want) + } + } +} + +func TestAccessController_FailClosed(t *testing.T) { + ac, _ := NewAccessController(AccessModeIntranet, nil) + if ac.Allow(nil) { + t.Error("nil addr must be denied") + } + var nilAC *AccessController + if nilAC.Allow(mustTCPAddr(t, "127.0.0.1:1")) { + t.Error("nil controller must deny") + } +} diff --git a/internal/proxy/forward.go b/internal/proxy/forward.go index 3ac899f3..a8ea0de1 100644 --- a/internal/proxy/forward.go +++ b/internal/proxy/forward.go @@ -27,6 +27,13 @@ type ForwardProxyConfig struct { MetricsSink MetricsEventSink OutboundTransport OutboundTransportConfig TransportPool *OutboundTransportPool + + // Free-mode (password-less) port support. When ForcedPlatform is non-empty, + // the proxy skips Proxy-Authorization and routes every request as + // (ForcedPlatform, account). With AccountFromLocalPort, the account is + // derived from the connection's local port so each port pins its own lease. + ForcedPlatform string + AccountFromLocalPort bool } // ForwardProxy implements an HTTP forward proxy with Proxy-Authorization @@ -42,6 +49,9 @@ type ForwardProxy struct { transportConfig OutboundTransportConfig transportPool *OutboundTransportPool transportPoolOnce sync.Once + + forcedPlatform string + accountFromLocalPort bool } // NewForwardProxy creates a new forward proxy handler. @@ -69,6 +79,9 @@ func NewForwardProxy(cfg ForwardProxyConfig) *ForwardProxy { metricsSink: cfg.MetricsSink, transportConfig: transportCfg, transportPool: transportPool, + + forcedPlatform: cfg.ForcedPlatform, + accountFromLocalPort: cfg.AccountFromLocalPort, } } @@ -99,6 +112,20 @@ func (p *ForwardProxy) effectiveAuthVersion() config.AuthVersion { return config.AuthVersionLegacyV0 } +// resolveIdentity returns the (platformName, account) for a request. In +// free-mode (ForcedPlatform set) it bypasses Proxy-Authorization and derives a +// per-port sticky account; otherwise it parses Proxy-Authorization as usual. +func (p *ForwardProxy) resolveIdentity(r *http.Request) (string, string, *ProxyError) { + if p.forcedPlatform != "" { + account := "" + if p.accountFromLocalPort { + account = freeAccountFromRequest(r) + } + return p.forcedPlatform, account, nil + } + return p.authenticate(r) +} + // authenticate parses Proxy-Authorization and returns (platformName, account, error). func (p *ForwardProxy) authenticate(r *http.Request) (string, string, *ProxyError) { if p.effectiveAuthVersion() == config.AuthVersionV1 { @@ -293,7 +320,7 @@ func prepareForwardOutboundRequest(in *http.Request) *http.Request { } func (p *ForwardProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { - platName, account, authErr := p.authenticate(r) + platName, account, authErr := p.resolveIdentity(r) if authErr != nil { writeProxyError(w, authErr) return @@ -375,7 +402,7 @@ func (p *ForwardProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { func (p *ForwardProxy) handleCONNECT(w http.ResponseWriter, r *http.Request) { target := r.Host - platName, account, authErr := p.authenticate(r) + platName, account, authErr := p.resolveIdentity(r) if authErr != nil { writeProxyError(w, authErr) return diff --git a/internal/proxy/free_port.go b/internal/proxy/free_port.go new file mode 100644 index 00000000..f269c492 --- /dev/null +++ b/internal/proxy/free_port.go @@ -0,0 +1,44 @@ +package proxy + +import ( + "net" + "net/http" + "strconv" +) + +// freeAccountPrefix namespaces password-less ("free-mode") port accounts so +// they never collide with real business accounts and are recognizable in the +// lease panel. +const freeAccountPrefix = "__free_" + +// freeAccountFromPort derives the sticky-lease account for a free-mode port. +// Each port maps to a distinct account, so each port pins its own sticky lease +// (and thus its own egress node/IP) within the bound platform. +func freeAccountFromPort(port int) string { + return freeAccountPrefix + strconv.Itoa(port) +} + +// freeAccountFromAddr derives the account from a local listen address +// (host:port). Falls back to the bare prefix when the port cannot be parsed. +func freeAccountFromAddr(local net.Addr) string { + if local != nil { + if _, portStr, err := net.SplitHostPort(local.String()); err == nil { + if port, err := strconv.Atoi(portStr); err == nil { + return freeAccountFromPort(port) + } + } + } + return freeAccountPrefix +} + +// freeAccountFromRequest derives the account for an HTTP forward-proxy request +// served on a free-mode port, using the connection's local address exposed via +// http.LocalAddrContextKey. +func freeAccountFromRequest(r *http.Request) string { + if r != nil { + if local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { + return freeAccountFromAddr(local) + } + } + return freeAccountPrefix +} diff --git a/internal/proxy/free_port_test.go b/internal/proxy/free_port_test.go new file mode 100644 index 00000000..7c1530b2 --- /dev/null +++ b/internal/proxy/free_port_test.go @@ -0,0 +1,21 @@ +package proxy + +import ( + "net" + "testing" +) + +func TestFreeAccountFromPort(t *testing.T) { + if got := freeAccountFromPort(21000); got != "__free_21000" { + t.Errorf("freeAccountFromPort(21000) = %q, want __free_21000", got) + } +} + +func TestFreeAccountFromAddr(t *testing.T) { + if got := freeAccountFromAddr(&net.TCPAddr{Port: 21005}); got != "__free_21005" { + t.Errorf("freeAccountFromAddr(:21005) = %q, want __free_21005", got) + } + if got := freeAccountFromAddr(nil); got != freeAccountPrefix { + t.Errorf("freeAccountFromAddr(nil) = %q, want %q", got, freeAccountPrefix) + } +} diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index fffdb651..1e57f53f 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -44,6 +44,13 @@ type Socks5InboundConfig struct { Health HealthRecorder Events EventEmitter MetricsSink MetricsEventSink + + // Free-mode (password-less) port support. When ForcedPlatform is non-empty, + // any client-supplied identity is ignored and every session routes as + // (ForcedPlatform, account); with AccountFromLocalPort the account is + // derived from the connection's local port so each port pins its own lease. + ForcedPlatform string + AccountFromLocalPort bool } // Socks5Inbound implements SOCKS5 CONNECT over a raw TCP connection. @@ -52,6 +59,9 @@ type Socks5Inbound struct { authVersion config.AuthVersion tunnel tunnelDeps events EventEmitter + + forcedPlatform string + accountFromLocalPort bool } type socks5HandshakeResult struct { @@ -81,6 +91,9 @@ func NewSocks5Inbound(cfg Socks5InboundConfig) *Socks5Inbound { metricsSink: cfg.MetricsSink, }, events: ev, + + forcedPlatform: cfg.ForcedPlatform, + accountFromLocalPort: cfg.AccountFromLocalPort, } } @@ -111,6 +124,8 @@ func (s *Socks5Inbound) ServeConnContext(baseCtx context.Context, conn net.Conn) } handshakePhase.Stop() + platformName, account := s.resolveIdentity(handshake, conn) + lifecycle := newRequestLifecycleFromMetadata( s.events, conn.RemoteAddr().String(), @@ -119,14 +134,14 @@ func (s *Socks5Inbound) ServeConnContext(baseCtx context.Context, conn net.Conn) true, ) lifecycle.setTarget(handshake.target, "") - lifecycle.setAccount(handshake.account) + lifecycle.setAccount(account) defer lifecycle.finish() prepare := prepareConnectTunnel( baseCtx, s.tunnel, - handshake.platformName, - handshake.account, + platformName, + account, handshake.target, ) if prepare.route.PlatformID != "" { @@ -165,6 +180,20 @@ func (s *Socks5Inbound) ServeConnContext(baseCtx context.Context, conn net.Conn) prepare.session.recordResult(relay.netOK) } +// resolveIdentity returns the (platform, account) to route with. In free-mode +// (forcedPlatform set) it ignores any client-supplied identity and derives a +// per-port sticky account; otherwise it uses the handshake identity. +func (s *Socks5Inbound) resolveIdentity(h socks5HandshakeResult, conn net.Conn) (string, string) { + if s.forcedPlatform == "" { + return h.platformName, h.account + } + account := "" + if s.accountFromLocalPort && conn != nil { + account = freeAccountFromAddr(conn.LocalAddr()) + } + return s.forcedPlatform, account +} + func (s *Socks5Inbound) performHandshake(conn net.Conn, reader *bufio.Reader) socks5HandshakeResult { if s.authVersion != config.AuthVersionV1 { _, _ = conn.Write([]byte{socks5Version, socks5MethodNoAcceptable}) From c8083ce5305c1921cd31f406f2c9c9c22d426b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Fri, 19 Jun 2026 16:40:35 +0800 Subject: [PATCH 12/25] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=A7=E8=A7=84?= =?UTF-8?q?=E6=A8=A1=E8=8A=82=E7=82=B9=E4=B8=8B=E5=90=AF=E5=8A=A8=20CPU/?= =?UTF-8?q?=E7=A3=81=E7=9B=98=20IO=20=E5=8D=A0=E7=94=A8=E8=BF=87=E9=AB=98?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/config/env.go: RESIN_PROBE_CONCURRENCY 默认值由 1000 下调至 64,避免数千节点时启动瞬间拉起上千并发出站探测打满 CPU/网络/磁盘 - internal/config/env_test.go: 同步更新默认值断言 - docker-compose.yml.example: 新增 GOMAXPROCS/GOMEMLIMIT 与 deploy.resources.limits,让 Go 运行时遵循容器配额(约束多处 GOMAXPROCS(0) 工作池与 GC);免密端口映射由 21000-22000 收窄至 21000-21049,消除按端口派生的大量 docker-proxy 进程 --- docker-compose.yml.example | 26 ++++++++++++++++++++++---- internal/config/env.go | 5 ++++- internal/config/env_test.go | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 8695fea5..a8deb2ea 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -22,12 +22,30 @@ services: RESIN_FREE_PORT_ACCESS_MODE: intranet # RESIN_FREE_PORT_WHITELIST: '["10.0.0.0/8","192.168.0.0/16"]' # whitelist mode only # TZ: + # --- Resource / startup tuning (large node pools) --- + # Each probe worker opens a real outbound and does an HTTPS request. The + # default (64) is safe; raise only on big hosts with many nodes. + RESIN_PROBE_CONCURRENCY: 64 + # Let the Go runtime honor the container's CPU/memory limits below. Without + # these, Go sees ALL host cores (GOMAXPROCS) and may over-parallelize + # startup work (outbound warmup, subscription refresh) and over-use memory. + GOMAXPROCS: "2" + GOMEMLIMIT: "900MiB" # ~85% of the memory limit below + # Caps how much CPU/RAM the container (and thus the Go runtime) may use. + # Honored by `docker compose up` (Compose v2). + deploy: + resources: + limits: + cpus: "2" + memory: 1g ports: - "2260:2260" - # Free-mode range. NOTE: mapping the whole span spawns ~1000 docker-proxy - # processes (slow start, more memory). For production, narrow this to your - # actual RESIN_FREE_PORT_COUNT, or use `network_mode: host` to bypass it. - - "21000-22000:21000-22000" + # Free-mode range. Map ONLY the ports you actually open + # (RESIN_FREE_PORT_START .. +RESIN_FREE_PORT_COUNT-1). Mapping a wider span + # spawns one docker-proxy process PER port (slow start, more memory). + # For large ranges or production, prefer `network_mode: host` (drop this + # block). The example matches RESIN_FREE_PORT_START=21000 / COUNT=50. + - "21000-21049:21000-21049" volumes: - resin_cache:/var/cache/resin - resin_state:/var/lib/resin diff --git a/internal/config/env.go b/internal/config/env.go index 456d5c65..40b0860b 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -102,7 +102,10 @@ func LoadEnvConfig() (*EnvConfig, error) { // --- Core --- cfg.MaxLatencyTableEntries = envInt("RESIN_MAX_LATENCY_TABLE_ENTRIES", 12, &errs) - cfg.ProbeConcurrency = envInt("RESIN_PROBE_CONCURRENCY", 1000, &errs) + // Default kept modest: each probe worker opens a real outbound (singbox) and + // performs an HTTPS request. With thousands of nodes, a high concurrency causes + // a CPU/network/disk-IO storm at startup. High-scale deployments can raise this. + cfg.ProbeConcurrency = envInt("RESIN_PROBE_CONCURRENCY", 64, &errs) cfg.GeoIPUpdateSchedule = envStr("RESIN_GEOIP_UPDATE_SCHEDULE", "0 7 * * *") cfg.DefaultPlatformStickyTTL = envDuration("RESIN_DEFAULT_PLATFORM_STICKY_TTL", 7*24*time.Hour, &errs) cfg.DefaultPlatformRegexFilters = envStringSlice("RESIN_DEFAULT_PLATFORM_REGEX_FILTERS", []string{}, &errs) diff --git a/internal/config/env_test.go b/internal/config/env_test.go index eb2c5a87..a9fa19c7 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -44,7 +44,7 @@ func TestLoadEnvConfig_Defaults(t *testing.T) { // Core assertEqual(t, "MaxLatencyTableEntries", cfg.MaxLatencyTableEntries, 12) - assertEqual(t, "ProbeConcurrency", cfg.ProbeConcurrency, 1000) + assertEqual(t, "ProbeConcurrency", cfg.ProbeConcurrency, 64) assertEqual(t, "GeoIPUpdateSchedule", cfg.GeoIPUpdateSchedule, "0 7 * * *") assertEqual(t, "DefaultPlatformStickyTTL", cfg.DefaultPlatformStickyTTL, 7*24*time.Hour) assertEqual(t, "DefaultPlatformRegexFiltersLength", len(cfg.DefaultPlatformRegexFilters), 0) From bf4c7675d2647d2c0295b0bfcfbc7cfabfdea529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 24 Jun 2026 21:28:23 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat(subscription):=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=B5=81=E9=87=8F=E7=94=A8=E9=87=8F=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增订阅流量用量字段(上传/下载/总量/到期时间) - 新增路由分配策略 PreferIdleIP,优先使用空闲 IP 避免并发冲突 - 下载器支持保留响应头,用于解析订阅用量信息 - 订阅更新时自动解析 Subscription-Userinfo 响应头 - 前端订阅页面展示流量用量与到期时间 - 新增数据库迁移脚本支持用量字段持久化 --- cmd/resin/main.go | 35 +++++++++ internal/model/models.go | 5 ++ internal/netutil/downloader.go | 36 +++++++-- internal/netutil/retry_downloader.go | 53 ++++++++++--- internal/routing/router.go | 77 +++++++++++++++++++ internal/routing/routing_test.go | 72 +++++++++++++++++ internal/routing/state.go | 7 +- .../service/control_plane_subscription.go | 62 +++++++++++---- internal/state/migrate.go | 7 ++ internal/state/repo_state.go | 23 ++++-- internal/state/repo_state_test.go | 60 +++++++++++++-- internal/subscription/subscription.go | 24 ++++++ internal/topology/scheduler_test.go | 31 ++++++++ internal/topology/subscription_scheduler.go | 65 +++++++++++++--- .../subscriptions/SubscriptionPage.tsx | 47 +++++++++++ webui/src/features/subscriptions/api.ts | 2 + webui/src/features/subscriptions/types.ts | 9 +++ webui/src/i18n/translations.ts | 4 + 18 files changed, 566 insertions(+), 53 deletions(-) diff --git a/cmd/resin/main.go b/cmd/resin/main.go index be6f65fe..ac79b80b 100644 --- a/cmd/resin/main.go +++ b/cmd/resin/main.go @@ -338,6 +338,11 @@ func newTopologyRuntime( SubManager: subManager, Pool: pool, Downloader: downloader, + OnSubUpdated: func(sub *subscription.Subscription) { + if err := engine.UpsertSubscription(runtimeSubscriptionToModel(sub)); err != nil { + log.Printf("persist subscription %s: %v", sub.ID, err) + } + }, OnSubReenabledNode: func(hash node.Hash) { outboundMgr.EnsureNodeOutbound(hash) probeMgr.TriggerImmediateEgressProbe(hash) @@ -394,6 +399,13 @@ func bootstrapTopology( sub.SetContent(ms.Content) sub.SetIncrementalAliveNodes(ms.IncrementalAliveNodes) sub.SetEphemeralNodeEvictDelayNs(ms.EphemeralNodeEvictDelayNs) + sub.SetUsage(subscription.UsageInfo{ + UploadBytes: ms.UsageUploadBytes, + DownloadBytes: ms.UsageDownloadBytes, + TotalBytes: ms.UsageTotalBytes, + ExpireUnix: ms.UsageExpireUnix, + UpdatedAtNs: ms.UsageUpdatedAtNs, + }) sub.CreatedAtNs = ms.CreatedAtNs sub.UpdatedAtNs = ms.UpdatedAtNs subManager.Register(sub) @@ -430,6 +442,29 @@ func bootstrapTopology( return nil } +func runtimeSubscriptionToModel(sub *subscription.Subscription) model.Subscription { + usage := sub.Usage() + return model.Subscription{ + ID: sub.ID, + Name: sub.Name(), + SourceType: sub.SourceType(), + URL: sub.URL(), + Content: sub.Content(), + UpdateIntervalNs: sub.UpdateIntervalNs(), + Enabled: sub.Enabled(), + Ephemeral: sub.Ephemeral(), + IncrementalAliveNodes: sub.IncrementalAliveNodes(), + EphemeralNodeEvictDelayNs: sub.EphemeralNodeEvictDelayNs(), + UsageUploadBytes: usage.UploadBytes, + UsageDownloadBytes: usage.DownloadBytes, + UsageTotalBytes: usage.TotalBytes, + UsageExpireUnix: usage.ExpireUnix, + UsageUpdatedAtNs: usage.UpdatedAtNs, + CreatedAtNs: sub.CreatedAtNs, + UpdatedAtNs: sub.UpdatedAtNs, + } +} + func validatePersistedPlatformNamesForV1(platformsInDB []model.Platform) error { var invalidPlatformNames []string for _, p := range platformsInDB { diff --git a/internal/model/models.go b/internal/model/models.go index 8e1396b9..90ce4246 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -29,6 +29,11 @@ type Subscription struct { Ephemeral bool `json:"ephemeral"` IncrementalAliveNodes bool `json:"incremental_alive_nodes"` EphemeralNodeEvictDelayNs int64 `json:"ephemeral_node_evict_delay_ns"` + UsageUploadBytes int64 `json:"usage_upload_bytes"` + UsageDownloadBytes int64 `json:"usage_download_bytes"` + UsageTotalBytes int64 `json:"usage_total_bytes"` + UsageExpireUnix int64 `json:"usage_expire_unix"` + UsageUpdatedAtNs int64 `json:"usage_updated_at_ns"` CreatedAtNs int64 `json:"created_at_ns"` UpdatedAtNs int64 `json:"updated_at_ns"` } diff --git a/internal/netutil/downloader.go b/internal/netutil/downloader.go index cc6f2bbd..323913c9 100644 --- a/internal/netutil/downloader.go +++ b/internal/netutil/downloader.go @@ -39,6 +39,20 @@ type Downloader interface { Download(ctx context.Context, url string) ([]byte, error) } +// DownloadResponse contains the response body plus HTTP metadata relevant to +// callers that need subscription headers. +type DownloadResponse struct { + Body []byte + Header http.Header +} + +// MetadataDownloader fetches remote resources while preserving response +// metadata. Downloader remains the compatibility interface for body-only +// callers. +type MetadataDownloader interface { + DownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) +} + // DirectDownloader downloads via a standard HTTP client (no proxy). type DirectDownloader struct { Client *http.Client @@ -64,6 +78,15 @@ func NewDirectDownloader(timeoutFn func() time.Duration, userAgentFn func() stri // Download fetches the URL and returns the response body. func (d *DirectDownloader) Download(ctx context.Context, url string) ([]byte, error) { + resp, err := d.DownloadWithMetadata(ctx, url) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +// DownloadWithMetadata fetches the URL and returns the response body and headers. +func (d *DirectDownloader) DownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) { if ctx == nil { ctx = context.Background() } @@ -76,7 +99,7 @@ func (d *DirectDownloader) Download(ctx context.Context, url string) ([]byte, er req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, &NonRetryableError{Err: err} + return DownloadResponse{}, &NonRetryableError{Err: err} } userAgent := d.currentUserAgent() if userAgent != "" { @@ -89,19 +112,22 @@ func (d *DirectDownloader) Download(ctx context.Context, url string) ([]byte, er } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("downloader: %w", err) + return DownloadResponse{}, fmt.Errorf("downloader: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, &HTTPStatusError{StatusCode: resp.StatusCode, URL: url} + return DownloadResponse{}, &HTTPStatusError{StatusCode: resp.StatusCode, URL: url} } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("downloader: %w", err) + return DownloadResponse{}, fmt.Errorf("downloader: %w", err) } - return body, nil + return DownloadResponse{ + Body: body, + Header: resp.Header.Clone(), + }, nil } func (d *DirectDownloader) currentTimeout() time.Duration { diff --git a/internal/netutil/retry_downloader.go b/internal/netutil/retry_downloader.go index dfb3d68e..58b32bee 100644 --- a/internal/netutil/retry_downloader.go +++ b/internal/netutil/retry_downloader.go @@ -17,30 +17,41 @@ type RetryDownloader struct { ProxyAttemptTimeout time.Duration NodePicker func(target string) (node.Hash, error) ProxyFetch func(ctx context.Context, hash node.Hash, url string) ([]byte, error) + ProxyFetchMetadata func(ctx context.Context, hash node.Hash, url string) (DownloadResponse, error) } // Download attempts direct download first, then falls back to proxy retries. func (r *RetryDownloader) Download(ctx context.Context, url string) ([]byte, error) { + resp, err := r.DownloadWithMetadata(ctx, url) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +// DownloadWithMetadata attempts direct download first, then falls back to proxy +// retries while preserving response headers when the selected path provides them. +func (r *RetryDownloader) DownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) { if ctx == nil { ctx = context.Background() } - body, err := r.Direct.Download(ctx, url) + resp, err := r.directDownloadWithMetadata(ctx, url) if err == nil { - return body, nil + return resp, nil } if !shouldRetryViaProxy(err) { - return nil, err + return DownloadResponse{}, err } - if r.NodePicker == nil || r.ProxyFetch == nil { - return nil, err + if r.NodePicker == nil || (r.ProxyFetch == nil && r.ProxyFetchMetadata == nil) { + return DownloadResponse{}, err } // Respect caller cancellation/deadline: don't extend lifecycle beyond caller ctx. if ctx.Err() != nil { - return nil, err + return DownloadResponse{}, err } attemptTimeout := r.proxyAttemptTimeout() @@ -48,7 +59,7 @@ func (r *RetryDownloader) Download(ctx context.Context, url string) ([]byte, err // Retry 2 times with random proxy nodes. for i := 0; i < 2; i++ { if ctx.Err() != nil { - return nil, err + return DownloadResponse{}, err } hash, pickErr := r.NodePicker(url) @@ -61,14 +72,36 @@ func (r *RetryDownloader) Download(ctx context.Context, url string) ([]byte, err if attemptTimeout > 0 { attemptCtx, cancel = context.WithTimeout(ctx, attemptTimeout) } - body, fetchErr := r.ProxyFetch(attemptCtx, hash, url) + resp, fetchErr := r.proxyFetchWithMetadata(attemptCtx, hash, url) cancel() if fetchErr == nil { - return body, nil + return resp, nil } } - return nil, err + return DownloadResponse{}, err +} + +func (r *RetryDownloader) directDownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) { + if direct, ok := r.Direct.(MetadataDownloader); ok { + return direct.DownloadWithMetadata(ctx, url) + } + body, err := r.Direct.Download(ctx, url) + if err != nil { + return DownloadResponse{}, err + } + return DownloadResponse{Body: body}, nil +} + +func (r *RetryDownloader) proxyFetchWithMetadata(ctx context.Context, hash node.Hash, url string) (DownloadResponse, error) { + if r.ProxyFetchMetadata != nil { + return r.ProxyFetchMetadata(ctx, hash, url) + } + body, err := r.ProxyFetch(ctx, hash, url) + if err != nil { + return DownloadResponse{}, err + } + return DownloadResponse{Body: body}, nil } func shouldRetryViaProxy(err error) bool { diff --git a/internal/routing/router.go b/internal/routing/router.go index 21fda080..f31fc412 100644 --- a/internal/routing/router.go +++ b/internal/routing/router.go @@ -229,6 +229,9 @@ func (r *Router) createOrAbortStickyLease( hadPreviousLease bool, invalidation leaseInvalidationReason, ) (Lease, xsync.ComputeOp, RouteResult, error) { + state.allocationMu.Lock() + defer state.allocationMu.Unlock() + newLease, createdResult, err := r.createLease(plat, state, targetDomain, now, nowNs) if err != nil { r.cleanupPreviousLease(state, previous, hadPreviousLease, invalidation, plat.ID, account) @@ -413,6 +416,12 @@ func (r *Router) selectLiveRandomRoute( stats *IPLoadStats, targetDomain string, ) (node.Hash, *node.NodeEntry, error) { + if plat.AllocationPolicy == platform.AllocationPolicyPreferIdleIP { + if h, entry, ok := r.selectIdleIPRoute(plat, stats, targetDomain); ok { + return h, entry, nil + } + } + var lastMissing node.Hash for i := 0; i < livePickAttempts; i++ { h, err := randomRoute(plat, stats, r.pool, targetDomain, r.authorities(), r.p2cWindow()) @@ -431,6 +440,74 @@ func (r *Router) selectLiveRandomRoute( return node.Zero, nil, ErrNoAvailableNodes } +type idleIPRouteCandidate struct { + hash node.Hash + entry *node.NodeEntry + latency time.Duration + hasLatency bool +} + +func (r *Router) selectIdleIPRoute( + plat *platform.Platform, + stats *IPLoadStats, + targetDomain string, +) (node.Hash, *node.NodeEntry, bool) { + authorities := r.authorities() + window := r.p2cWindow() + candidates := make(map[netip.Addr]idleIPRouteCandidate) + + plat.View().Range(func(h node.Hash) bool { + entry, ok := r.pool.GetEntry(h) + if !ok { + return true + } + ip := entry.GetEgressIP() + if !ip.IsValid() || stats.Get(ip) > 0 { + return true + } + + latency, hasLatency := sameIPCandidateLatency(entry, targetDomain, authorities, window) + next := idleIPRouteCandidate{ + hash: h, + entry: entry, + latency: latency, + hasLatency: hasLatency, + } + if current, ok := candidates[ip]; !ok || isBetterIdleIPCandidate(next, current) { + candidates[ip] = next + } + return true + }) + + best, ok := bestIdleIPCandidate(candidates) + if !ok { + return node.Zero, nil, false + } + return best.hash, best.entry, true +} + +func bestIdleIPCandidate(candidates map[netip.Addr]idleIPRouteCandidate) (idleIPRouteCandidate, bool) { + var best idleIPRouteCandidate + ok := false + for _, candidate := range candidates { + if !ok || isBetterIdleIPCandidate(candidate, best) { + best = candidate + ok = true + } + } + return best, ok +} + +func isBetterIdleIPCandidate(next, current idleIPRouteCandidate) bool { + if next.hasLatency != current.hasLatency { + return next.hasLatency + } + if next.hasLatency && next.latency != current.latency { + return next.latency < current.latency + } + return next.hash.Hex() < current.hash.Hex() +} + func chooseSameIPRotationCandidate( plat *platform.Platform, pool PoolAccessor, diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go index e7b3901f..fff9ce43 100644 --- a/internal/routing/routing_test.go +++ b/internal/routing/routing_test.go @@ -202,6 +202,78 @@ func TestStickyLease_CreateAndHit(t *testing.T) { } } +func TestStickyLease_PreferIdleIPUsesEveryIdleIPBeforeRepeating(t *testing.T) { + pool, subMgr := setupPool(t) + plat, ok := pool.GetPlatform(platID) + if !ok { + t.Fatal("platform not found") + } + plat.AllocationPolicy = platform.AllocationPolicyPreferIdleIP + + makeRoutableNode(t, pool, subMgr, `{"idle":"1a"}`, "10.0.0.1", "cloudflare.com", 50*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle":"1b"}`, "10.0.0.1", "cloudflare.com", 40*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle":"2"}`, "10.0.0.2", "cloudflare.com", 60*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle":"3"}`, "10.0.0.3", "cloudflare.com", 70*time.Millisecond) + + router := makeRouter(pool, nil) + seen := map[netip.Addr]bool{} + for _, account := range []string{"user-idle-a", "user-idle-b", "user-idle-c"} { + res, err := router.RouteRequest(platName, account, "example.com") + if err != nil { + t.Fatalf("route %s: %v", account, err) + } + if seen[res.EgressIP] { + t.Fatalf("egress IP %s repeated before all idle IPs were used", res.EgressIP) + } + seen[res.EgressIP] = true + } + if len(seen) != 3 { + t.Fatalf("used idle IP count = %d, want 3", len(seen)) + } +} + +func TestStickyLease_PreferIdleIPConcurrentCreatesDoNotRepeatIdleIP(t *testing.T) { + pool, subMgr := setupPool(t) + plat, ok := pool.GetPlatform(platID) + if !ok { + t.Fatal("platform not found") + } + plat.AllocationPolicy = platform.AllocationPolicyPreferIdleIP + + makeRoutableNode(t, pool, subMgr, `{"idle-concurrent":"1"}`, "10.0.1.1", "cloudflare.com", 50*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle-concurrent":"2"}`, "10.0.1.2", "cloudflare.com", 50*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle-concurrent":"3"}`, "10.0.1.3", "cloudflare.com", 50*time.Millisecond) + + router := makeRouter(pool, nil) + var wg sync.WaitGroup + results := make(chan netip.Addr, 3) + for _, account := range []string{"concurrent-a", "concurrent-b", "concurrent-c"} { + wg.Add(1) + go func(account string) { + defer wg.Done() + res, err := router.RouteRequest(platName, account, "example.com") + if err != nil { + t.Errorf("route %s: %v", account, err) + return + } + results <- res.EgressIP + }(account) + } + wg.Wait() + close(results) + + seen := map[netip.Addr]bool{} + for ip := range results { + if seen[ip] { + t.Fatalf("egress IP %s repeated during concurrent idle allocation", ip) + } + seen[ip] = true + } + if len(seen) != 3 { + t.Fatalf("allocated IP count = %d, want 3", len(seen)) + } +} + func TestDeleteLease_EmitsLeaseRemoveWithLifetimeFields(t *testing.T) { pool, subMgr := setupPool(t) makeRoutableNode(t, pool, subMgr, `{"delete":"1"}`, "10.0.0.9", "cloudflare.com", 50*time.Millisecond) diff --git a/internal/routing/state.go b/internal/routing/state.go index dd9210be..f48320a7 100644 --- a/internal/routing/state.go +++ b/internal/routing/state.go @@ -1,10 +1,13 @@ package routing +import "sync" + // PlatformRoutingState encapsulates the routing state for a single platform. // This struct is stored in the Router's state map. type PlatformRoutingState struct { - Leases *LeaseTable - IPLoadStats *IPLoadStats + Leases *LeaseTable + IPLoadStats *IPLoadStats + allocationMu sync.Mutex } // NewPlatformRoutingState creates a new state instance. diff --git a/internal/service/control_plane_subscription.go b/internal/service/control_plane_subscription.go index 9985f325..1ba4a1b5 100644 --- a/internal/service/control_plane_subscription.go +++ b/internal/service/control_plane_subscription.go @@ -22,22 +22,31 @@ import ( // SubscriptionResponse is the API response for a subscription. type SubscriptionResponse struct { - ID string `json:"id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - URL string `json:"url"` - Content string `json:"content"` - UpdateInterval string `json:"update_interval"` - NodeCount int `json:"node_count"` - HealthyNodeCount int `json:"healthy_node_count"` - Ephemeral bool `json:"ephemeral"` - IncrementalAliveNodes bool `json:"incremental_alive_nodes"` - EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"` - Enabled bool `json:"enabled"` - CreatedAt string `json:"created_at"` - LastChecked string `json:"last_checked,omitempty"` - LastUpdated string `json:"last_updated,omitempty"` - LastError string `json:"last_error,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + URL string `json:"url"` + Content string `json:"content"` + UpdateInterval string `json:"update_interval"` + NodeCount int `json:"node_count"` + HealthyNodeCount int `json:"healthy_node_count"` + Ephemeral bool `json:"ephemeral"` + IncrementalAliveNodes bool `json:"incremental_alive_nodes"` + EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + LastChecked string `json:"last_checked,omitempty"` + LastUpdated string `json:"last_updated,omitempty"` + LastError string `json:"last_error,omitempty"` + Usage *SubscriptionUsageResponse `json:"usage,omitempty"` +} + +type SubscriptionUsageResponse struct { + UploadBytes int64 `json:"upload_bytes"` + DownloadBytes int64 `json:"download_bytes"` + TotalBytes int64 `json:"total_bytes"` + ExpireUnix int64 `json:"expire_unix,omitempty"` + UpdatedAt string `json:"updated_at"` } func (s *ControlPlaneService) subToResponse(sub *subscription.Subscription) SubscriptionResponse { @@ -84,10 +93,30 @@ func (s *ControlPlaneService) subToResponse(sub *subscription.Subscription) Subs if lu := sub.LastUpdatedNs.Load(); lu > 0 { resp.LastUpdated = time.Unix(0, lu).UTC().Format(time.RFC3339Nano) } + if usage := sub.Usage(); usage.UpdatedAtNs > 0 { + resp.Usage = &SubscriptionUsageResponse{ + UploadBytes: usage.UploadBytes, + DownloadBytes: usage.DownloadBytes, + TotalBytes: usage.TotalBytes, + ExpireUnix: usage.ExpireUnix, + UpdatedAt: time.Unix(0, usage.UpdatedAtNs).UTC().Format(time.RFC3339Nano), + } + } resp.LastError = sub.GetLastError() return resp } +func subscriptionUsageToModelFields(usage subscription.UsageInfo, ms *model.Subscription) { + if ms == nil { + return + } + ms.UsageUploadBytes = usage.UploadBytes + ms.UsageDownloadBytes = usage.DownloadBytes + ms.UsageTotalBytes = usage.TotalBytes + ms.UsageExpireUnix = usage.ExpireUnix + ms.UsageUpdatedAtNs = usage.UpdatedAtNs +} + // ListSubscriptions returns all subscriptions, optionally filtered by enabled. func (s *ControlPlaneService) ListSubscriptions(enabled *bool) ([]SubscriptionResponse, error) { var result []SubscriptionResponse @@ -379,6 +408,7 @@ func (s *ControlPlaneService) UpdateSubscription(id string, patchJSON json.RawMe CreatedAtNs: sub.CreatedAtNs, UpdatedAtNs: now, } + subscriptionUsageToModelFields(sub.Usage(), &ms) if err := s.Engine.UpsertSubscription(ms); err != nil { return nil, internal("persist subscription", err) } diff --git a/internal/state/migrate.go b/internal/state/migrate.go index 70e2387b..97d38e64 100644 --- a/internal/state/migrate.go +++ b/internal/state/migrate.go @@ -24,6 +24,7 @@ const ( stateVersionAddFixedAccountHeader = 3 stateVersionNormalizeMissAction = 4 stateVersionAddIncrementalAliveNodes = 5 + stateVersionAddSubscriptionUsageInfo = 6 stateLegacyBaselineVersion = stateVersionAddFixedAccountHeader stateBaseSchemaMigration = stateMigrationsPath + "/000001_state_base.up.sql" @@ -111,8 +112,14 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { if err != nil { return err } + hasSubscriptionUsageInfo, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns") + if err != nil { + return err + } switch { + case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasSubscriptionUsageInfo: + return setLegacyMigrationVersion(db, driver, stateVersionAddSubscriptionUsageInfo) case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes: return setLegacyMigrationVersion(db, driver, stateVersionAddIncrementalAliveNodes) case hasEmptyBehavior && hasFixedHeader: diff --git a/internal/state/repo_state.go b/internal/state/repo_state.go index cea88d2e..23003094 100644 --- a/internal/state/repo_state.go +++ b/internal/state/repo_state.go @@ -300,8 +300,10 @@ func (r *StateRepo) UpsertSubscription(s model.Subscription) error { _, err := r.db.Exec(` INSERT INTO subscriptions (id, name, source_type, url, content, update_interval_ns, enabled, - ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, created_at_ns, updated_at_ns) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, + usage_upload_bytes, usage_download_bytes, usage_total_bytes, + usage_expire_unix, usage_updated_at_ns, created_at_ns, updated_at_ns) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, source_type = excluded.source_type, @@ -312,9 +314,16 @@ func (r *StateRepo) UpsertSubscription(s model.Subscription) error { ephemeral = excluded.ephemeral, incremental_alive_nodes = excluded.incremental_alive_nodes, ephemeral_node_evict_delay_ns = excluded.ephemeral_node_evict_delay_ns, + usage_upload_bytes = excluded.usage_upload_bytes, + usage_download_bytes = excluded.usage_download_bytes, + usage_total_bytes = excluded.usage_total_bytes, + usage_expire_unix = excluded.usage_expire_unix, + usage_updated_at_ns = excluded.usage_updated_at_ns, updated_at_ns = excluded.updated_at_ns `, s.ID, s.Name, s.SourceType, s.URL, s.Content, s.UpdateIntervalNs, s.Enabled, - s.Ephemeral, s.IncrementalAliveNodes, s.EphemeralNodeEvictDelayNs, s.CreatedAtNs, s.UpdatedAtNs) + s.Ephemeral, s.IncrementalAliveNodes, s.EphemeralNodeEvictDelayNs, + s.UsageUploadBytes, s.UsageDownloadBytes, s.UsageTotalBytes, + s.UsageExpireUnix, s.UsageUpdatedAtNs, s.CreatedAtNs, s.UpdatedAtNs) return err } @@ -337,7 +346,9 @@ func (r *StateRepo) DeleteSubscription(id string) error { // ListSubscriptions returns all subscriptions. func (r *StateRepo) ListSubscriptions() ([]model.Subscription, error) { rows, err := r.db.Query(`SELECT id, name, source_type, url, content, update_interval_ns, enabled, - ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, created_at_ns, updated_at_ns FROM subscriptions`) + ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, + usage_upload_bytes, usage_download_bytes, usage_total_bytes, usage_expire_unix, usage_updated_at_ns, + created_at_ns, updated_at_ns FROM subscriptions`) if err != nil { return nil, err } @@ -347,7 +358,9 @@ func (r *StateRepo) ListSubscriptions() ([]model.Subscription, error) { for rows.Next() { var s model.Subscription if err := rows.Scan(&s.ID, &s.Name, &s.SourceType, &s.URL, &s.Content, &s.UpdateIntervalNs, &s.Enabled, - &s.Ephemeral, &s.IncrementalAliveNodes, &s.EphemeralNodeEvictDelayNs, &s.CreatedAtNs, &s.UpdatedAtNs); err != nil { + &s.Ephemeral, &s.IncrementalAliveNodes, &s.EphemeralNodeEvictDelayNs, + &s.UsageUploadBytes, &s.UsageDownloadBytes, &s.UsageTotalBytes, &s.UsageExpireUnix, &s.UsageUpdatedAtNs, + &s.CreatedAtNs, &s.UpdatedAtNs); err != nil { return nil, err } if s.SourceType == "" { diff --git a/internal/state/repo_state_test.go b/internal/state/repo_state_test.go index b271d977..f8a1bb34 100644 --- a/internal/state/repo_state_test.go +++ b/internal/state/repo_state_test.go @@ -103,12 +103,15 @@ func TestMigrateStateDB_LegacyBaselineAdvancesToLatest(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddIncrementalAliveNodes { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddIncrementalAliveNodes) + if version != stateVersionAddSubscriptionUsageInfo { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) } if ok, err := hasTableColumn(db, "subscriptions", "incremental_alive_nodes"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.incremental_alive_nodes, ok=%v err=%v", ok, err) } + if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { + t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) + } } func TestMigrateStateDB_AddsIncrementalAliveNodesToLegacySubscriptions(t *testing.T) { @@ -167,8 +170,11 @@ func TestMigrateStateDB_AddsIncrementalAliveNodesToLegacySubscriptions(t *testin if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddIncrementalAliveNodes { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddIncrementalAliveNodes) + if version != stateVersionAddSubscriptionUsageInfo { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) + } + if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { + t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) } } @@ -239,12 +245,15 @@ func TestMigrateStateDB_NormalizesLegacyRandomMissAction(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddIncrementalAliveNodes { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddIncrementalAliveNodes) + if version != stateVersionAddSubscriptionUsageInfo { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) } if ok, err := hasTableColumn(db, "subscriptions", "incremental_alive_nodes"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.incremental_alive_nodes, ok=%v err=%v", ok, err) } + if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { + t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) + } } // --- system_config --- @@ -614,6 +623,45 @@ func TestStateRepo_Subscription_LocalSourcePersists(t *testing.T) { } } +func TestStateRepo_Subscription_UsageInfoPersists(t *testing.T) { + repo := newTestStateRepo(t) + now := time.Now().UnixNano() + + s := model.Subscription{ + ID: "sub-usage", + Name: "UsageSub", + SourceType: "remote", + URL: "https://example.com/sub", + UpdateIntervalNs: int64(time.Hour), + Enabled: true, + Ephemeral: false, + EphemeralNodeEvictDelayNs: int64(72 * time.Hour), + UsageUploadBytes: 1024, + UsageDownloadBytes: 2048, + UsageTotalBytes: 4096, + UsageExpireUnix: 1893456000, + UsageUpdatedAtNs: now, + CreatedAtNs: now, + UpdatedAtNs: now, + } + if err := repo.UpsertSubscription(s); err != nil { + t.Fatal(err) + } + + list, err := repo.ListSubscriptions() + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("expected 1 subscription, got %d", len(list)) + } + got := list[0] + if got.UsageUploadBytes != 1024 || got.UsageDownloadBytes != 2048 || got.UsageTotalBytes != 4096 || + got.UsageExpireUnix != 1893456000 || got.UsageUpdatedAtNs != now { + t.Fatalf("unexpected usage fields: %+v", got) + } +} + // --- account_header_rules --- func TestStateRepo_AccountHeaderRules_CRUD(t *testing.T) { diff --git a/internal/subscription/subscription.go b/internal/subscription/subscription.go index 565fc395..e7913804 100644 --- a/internal/subscription/subscription.go +++ b/internal/subscription/subscription.go @@ -32,6 +32,15 @@ type ManagedNode struct { Evicted bool } +// UsageInfo contains optional usage metadata reported by a subscription source. +type UsageInfo struct { + UploadBytes int64 + DownloadBytes int64 + TotalBytes int64 + ExpireUnix int64 + UpdatedAtNs int64 +} + // ManagedNodes wraps hash->ManagedNode map. // // Maintenance rule: @@ -129,6 +138,7 @@ type Subscription struct { // ephemeralNodeEvictDelayNs is the per-subscription eviction delay for // circuit-broken nodes when Ephemeral is enabled. ephemeralNodeEvictDelayNs int64 + usage UsageInfo // Persistence timestamps (written under mu or single-writer context). CreatedAtNs int64 @@ -331,6 +341,20 @@ func (s *Subscription) SetEphemeralNodeEvictDelayNs(v int64) { s.mu.Unlock() } +// Usage returns the latest subscription usage metadata. +func (s *Subscription) Usage() UsageInfo { + s.mu.RLock() + defer s.mu.RUnlock() + return s.usage +} + +// SetUsage updates the latest subscription usage metadata. +func (s *Subscription) SetUsage(usage UsageInfo) { + s.mu.Lock() + s.usage = usage + s.mu.Unlock() +} + // ManagedNodes returns the current node view via atomic load. func (s *Subscription) ManagedNodes() *ManagedNodes { return s.managedNodes.Load() diff --git a/internal/topology/scheduler_test.go b/internal/topology/scheduler_test.go index 0e6a7628..d5b17f5f 100644 --- a/internal/topology/scheduler_test.go +++ b/internal/topology/scheduler_test.go @@ -94,6 +94,37 @@ func TestScheduler_UpdateSubscription_Success(t *testing.T) { } } +func TestScheduler_UpdateSubscription_StoresUsageFromResponseHeader(t *testing.T) { + subMgr := NewSubscriptionManager() + sub := subscription.NewSubscription("s1", "TestSub", "http://example.com", true, false) + sub.SetFetchConfig(sub.URL(), int64(time.Hour)) + subMgr.Register(sub) + + pool := newTestPool(subMgr) + body := makeSubscriptionJSON( + `{"type":"shadowsocks","tag":"us-1","server":"1.1.1.1","server_port":443}`, + ) + sched := NewSubscriptionScheduler(SchedulerConfig{ + SubManager: subMgr, + Pool: pool, + MetadataFetcher: func(string) (netutil.DownloadResponse, error) { + header := http.Header{} + header.Set(subscription.SubscriptionUserinfoHeader, "upload=1024; download=2048; total=4096; expire=1893456000") + return netutil.DownloadResponse{Body: body, Header: header}, nil + }, + }) + + sched.UpdateSubscription(sub) + + usage := sub.Usage() + if usage.UploadBytes != 1024 || usage.DownloadBytes != 2048 || usage.TotalBytes != 4096 || usage.ExpireUnix != 1893456000 { + t.Fatalf("unexpected usage: %+v", usage) + } + if usage.UpdatedAtNs == 0 { + t.Fatal("usage updated_at should be set") + } +} + func TestScheduler_UpdateSubscription_DownloadViaHTTPServer(t *testing.T) { subMgr := NewSubscriptionManager() pool := newTestPool(subMgr) diff --git a/internal/topology/subscription_scheduler.go b/internal/topology/subscription_scheduler.go index f3bb150b..128be23b 100644 --- a/internal/topology/subscription_scheduler.go +++ b/internal/topology/subscription_scheduler.go @@ -25,7 +25,8 @@ type SubscriptionScheduler struct { // Fetcher fetches subscription data from a URL. // Defaults to downloader.Download; injectable for testing. - Fetcher func(url string) ([]byte, error) + Fetcher func(url string) ([]byte, error) + MetadataFetcher func(url string) (netutil.DownloadResponse, error) // For persistence. onSubUpdated func(sub *subscription.Subscription) @@ -39,11 +40,13 @@ type SubscriptionScheduler struct { // SchedulerConfig configures the SubscriptionScheduler. type SchedulerConfig struct { - SubManager *SubscriptionManager - Pool *GlobalNodePool - Downloader netutil.Downloader // shared downloader - Fetcher func(url string) ([]byte, error) // optional, defaults to Downloader.Download - OnSubUpdated func(sub *subscription.Subscription) + SubManager *SubscriptionManager + Pool *GlobalNodePool + Downloader netutil.Downloader // shared downloader + Fetcher func(url string) ([]byte, error) // optional, defaults to Downloader.Download + // MetadataFetcher is optional and preserves response headers for subscription usage. + MetadataFetcher func(url string) (netutil.DownloadResponse, error) + OnSubUpdated func(sub *subscription.Subscription) // OnSubReenabledNode is fired after false->true enabled transition. OnSubReenabledNode func(hash node.Hash) } @@ -66,6 +69,11 @@ func NewSubscriptionScheduler(cfg SchedulerConfig) *SubscriptionScheduler { } else { sched.Fetcher = sched.fetchViaDownloader } + if cfg.MetadataFetcher != nil { + sched.MetadataFetcher = cfg.MetadataFetcher + } else if cfg.Fetcher == nil { + sched.MetadataFetcher = sched.fetchViaDownloaderWithMetadata + } return sched } @@ -200,13 +208,15 @@ func (s *SubscriptionScheduler) UpdateSubscription(sub *subscription.Subscriptio // 1. Fetch/read content (lock-free). var ( - body []byte - err error + body []byte + usage subscription.UsageInfo + hasUsage bool + err error ) if attemptSourceType == subscription.SourceTypeLocal { body = []byte(attemptContent) } else { - body, err = s.Fetcher(attemptURL) + body, usage, hasUsage, err = s.fetchRemoteSubscription(attemptURL) if err != nil { s.handleUpdateFailure(sub, attemptStartedNs, attemptSeq, attemptConfigVersion, "fetch", err) return @@ -323,6 +333,12 @@ func (s *SubscriptionScheduler) UpdateSubscription(sub *subscription.Subscriptio now := time.Now().UnixNano() sub.LastCheckedNs.Store(now) sub.LastUpdatedNs.Store(now) + if hasUsage { + usage.UpdatedAtNs = now + sub.SetUsage(usage) + } else { + sub.SetUsage(subscription.UsageInfo{}) + } sub.MarkAppliedAttempt(attemptSeq) sub.SetLastError("") applied = true @@ -450,3 +466,34 @@ func (s *SubscriptionScheduler) RenameSubscription(sub *subscription.Subscriptio func (s *SubscriptionScheduler) fetchViaDownloader(url string) ([]byte, error) { return s.downloader.Download(s.downloadCtx, url) } + +func (s *SubscriptionScheduler) fetchViaDownloaderWithMetadata(url string) (netutil.DownloadResponse, error) { + if downloader, ok := s.downloader.(netutil.MetadataDownloader); ok { + return downloader.DownloadWithMetadata(s.downloadCtx, url) + } + body, err := s.downloader.Download(s.downloadCtx, url) + if err != nil { + return netutil.DownloadResponse{}, err + } + return netutil.DownloadResponse{Body: body}, nil +} + +func (s *SubscriptionScheduler) fetchRemoteSubscription(url string) ([]byte, subscription.UsageInfo, bool, error) { + if s.MetadataFetcher != nil { + resp, err := s.MetadataFetcher(url) + if err != nil { + return nil, subscription.UsageInfo{}, false, err + } + usage, ok := subscription.ParseSubscriptionUserinfo( + resp.Header.Get(subscription.SubscriptionUserinfoHeader), + 0, + ) + return resp.Body, usage, ok, nil + } + + body, err := s.Fetcher(url) + if err != nil { + return nil, subscription.UsageInfo{}, false, err + } + return body, subscription.UsageInfo{}, false, nil +} diff --git a/webui/src/features/subscriptions/SubscriptionPage.tsx b/webui/src/features/subscriptions/SubscriptionPage.tsx index bbb5aee8..b74569de 100644 --- a/webui/src/features/subscriptions/SubscriptionPage.tsx +++ b/webui/src/features/subscriptions/SubscriptionPage.tsx @@ -18,6 +18,7 @@ import { Textarea } from "../../components/ui/Textarea"; import { ToastContainer } from "../../components/ui/Toast"; import { useToast } from "../../hooks/useToast"; import { useI18n } from "../../i18n"; +import { formatBytes } from "../../lib/bytes"; import { formatApiErrorMessage } from "../../lib/error-message"; import { formatDateTime, formatGoDuration, formatRelativeTime } from "../../lib/time"; import { @@ -120,6 +121,26 @@ function normalizeSubmitUpdateInterval(sourceType: SubscriptionSourceType, raw: return raw.trim(); } +function formatSubscriptionUsage(subscription: Subscription): string { + const usage = subscription.usage; + if (!usage) { + return "-"; + } + const usedBytes = Math.max(0, usage.upload_bytes || 0) + Math.max(0, usage.download_bytes || 0); + if (usage.total_bytes > 0) { + return `${formatBytes(usedBytes)} / ${formatBytes(usage.total_bytes)}`; + } + return formatBytes(usedBytes); +} + +function formatSubscriptionExpire(subscription: Subscription, formatter: (input: string) => string): string { + const expireUnix = subscription.usage?.expire_unix ?? 0; + if (expireUnix <= 0) { + return "-"; + } + return formatter(new Date(expireUnix * 1000).toISOString()); +} + export function SubscriptionPage() { const { t } = useI18n(); const [enabledFilter, setEnabledFilter] = useState("all"); @@ -470,6 +491,16 @@ export function SubscriptionPage() { return `${s.healthy_node_count} / ${s.node_count}`; }, }), + col.display({ + id: "usage", + header: t("用量"), + cell: (info) => formatSubscriptionUsage(info.row.original), + }), + col.display({ + id: "expire", + header: t("到期"), + cell: (info) => formatSubscriptionExpire(info.row.original, formatRelativeTime), + }), col.display({ id: "status", header: t("状态"), @@ -698,6 +729,22 @@ export function SubscriptionPage() { {t("上次更新")}

{formatDateTime(selectedSubscription.last_updated || "")}

+
+ {t("已用流量")} +

{formatSubscriptionUsage(selectedSubscription)}

+
+
+ {t("上传流量")} +

{selectedSubscription.usage ? formatBytes(selectedSubscription.usage.upload_bytes) : "-"}

+
+
+ {t("下载流量")} +

{selectedSubscription.usage ? formatBytes(selectedSubscription.usage.download_bytes) : "-"}

+
+
+ {t("到期时间")} +

{formatSubscriptionExpire(selectedSubscription, formatDateTime)}

+
{selectedSubscription.last_error ? ( diff --git a/webui/src/features/subscriptions/api.ts b/webui/src/features/subscriptions/api.ts index 4b883fb3..a4d9e96f 100644 --- a/webui/src/features/subscriptions/api.ts +++ b/webui/src/features/subscriptions/api.ts @@ -14,6 +14,7 @@ type ApiSubscription = Omit = { diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts index 7cb7a7e6..3cc8d4f1 100644 --- a/webui/src/i18n/translations.ts +++ b/webui/src/i18n/translations.ts @@ -96,6 +96,10 @@ const EXACT_ZH_TO_EN: Record = { "出口 IP": "Egress IP", "区域": "Region", "流量": "Traffic", + "用量": "Usage", + "到期": "Expires", + "已用流量": "Used Traffic", + "到期时间": "Expiration Time", "耗时": "Latency", "时间": "Time", "开始时间": "Start Time", From 7b1d4e425e53c30ab9da9126159ffe01409b8778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 24 Jun 2026 21:28:49 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat(subscription):=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=A8=E9=87=8F=E4=BF=A1=E6=81=AF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=B8=8E=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据库迁移:subscriptions 表增加用量字段(上传/下载/总量/过期时间/更新时间) - 实现 Subscription-Userinfo 响应头解析器 - 新增单元测试覆盖正常解析、字段过滤、空值处理场景 --- ...0006_subscriptions_add_usage_info.down.sql | 5 ++ ...000006_subscriptions_add_usage_info.up.sql | 5 ++ internal/subscription/userinfo.go | 49 +++++++++++++++++++ internal/subscription/userinfo_test.go | 29 +++++++++++ 4 files changed, 88 insertions(+) create mode 100644 internal/state/migrations/state/000006_subscriptions_add_usage_info.down.sql create mode 100644 internal/state/migrations/state/000006_subscriptions_add_usage_info.up.sql create mode 100644 internal/subscription/userinfo.go create mode 100644 internal/subscription/userinfo_test.go diff --git a/internal/state/migrations/state/000006_subscriptions_add_usage_info.down.sql b/internal/state/migrations/state/000006_subscriptions_add_usage_info.down.sql new file mode 100644 index 00000000..d381755c --- /dev/null +++ b/internal/state/migrations/state/000006_subscriptions_add_usage_info.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE subscriptions DROP COLUMN usage_updated_at_ns; +ALTER TABLE subscriptions DROP COLUMN usage_expire_unix; +ALTER TABLE subscriptions DROP COLUMN usage_total_bytes; +ALTER TABLE subscriptions DROP COLUMN usage_download_bytes; +ALTER TABLE subscriptions DROP COLUMN usage_upload_bytes; diff --git a/internal/state/migrations/state/000006_subscriptions_add_usage_info.up.sql b/internal/state/migrations/state/000006_subscriptions_add_usage_info.up.sql new file mode 100644 index 00000000..e7f56fc8 --- /dev/null +++ b/internal/state/migrations/state/000006_subscriptions_add_usage_info.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE subscriptions ADD COLUMN usage_upload_bytes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_download_bytes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_total_bytes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_expire_unix INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_updated_at_ns INTEGER NOT NULL DEFAULT 0; diff --git a/internal/subscription/userinfo.go b/internal/subscription/userinfo.go new file mode 100644 index 00000000..dae2058c --- /dev/null +++ b/internal/subscription/userinfo.go @@ -0,0 +1,49 @@ +package subscription + +import ( + "strconv" + "strings" +) + +const SubscriptionUserinfoHeader = "Subscription-Userinfo" + +// ParseSubscriptionUserinfo parses the common subscription usage header: +// upload=...; download=...; total=...; expire=... +func ParseSubscriptionUserinfo(raw string, updatedAtNs int64) (UsageInfo, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return UsageInfo{}, false + } + + info := UsageInfo{UpdatedAtNs: updatedAtNs} + found := false + for _, part := range strings.Split(raw, ";") { + key, value, ok := strings.Cut(part, "=") + if !ok { + continue + } + n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || n < 0 { + continue + } + + switch strings.ToLower(strings.TrimSpace(key)) { + case "upload": + info.UploadBytes = n + found = true + case "download": + info.DownloadBytes = n + found = true + case "total": + info.TotalBytes = n + found = true + case "expire": + info.ExpireUnix = n + found = true + } + } + if !found { + return UsageInfo{}, false + } + return info, true +} diff --git a/internal/subscription/userinfo_test.go b/internal/subscription/userinfo_test.go new file mode 100644 index 00000000..713e38cc --- /dev/null +++ b/internal/subscription/userinfo_test.go @@ -0,0 +1,29 @@ +package subscription + +import "testing" + +func TestParseSubscriptionUserinfo(t *testing.T) { + info, ok := ParseSubscriptionUserinfo("upload=1024; download=2048; total=4096; expire=1893456000", 123) + if !ok { + t.Fatal("expected userinfo to parse") + } + if info.UploadBytes != 1024 || info.DownloadBytes != 2048 || info.TotalBytes != 4096 || info.ExpireUnix != 1893456000 || info.UpdatedAtNs != 123 { + t.Fatalf("unexpected usage info: %+v", info) + } +} + +func TestParseSubscriptionUserinfo_IgnoresInvalidFields(t *testing.T) { + info, ok := ParseSubscriptionUserinfo("upload=bad; download=8; total=-1; expire=bad", 456) + if !ok { + t.Fatal("expected valid fields to parse") + } + if info.UploadBytes != 0 || info.DownloadBytes != 8 || info.TotalBytes != 0 || info.ExpireUnix != 0 || info.UpdatedAtNs != 456 { + t.Fatalf("unexpected usage info: %+v", info) + } +} + +func TestParseSubscriptionUserinfo_Empty(t *testing.T) { + if info, ok := ParseSubscriptionUserinfo("", 123); ok || info != (UsageInfo{}) { + t.Fatalf("empty userinfo parsed: ok=%v info=%+v", ok, info) + } +} From 89ead3a6e8db0c95f53de8bd61ae311061ea1c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Fri, 26 Jun 2026 15:39:24 +0800 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=9B=9E=E9=80=80=E8=AE=A2=E9=98=85=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - cmd/resin/app_runtime.go: 接入代理回退 metadata fetch,避免响应头丢失 - internal/netutil/outbound_http.go: 增加保留响应头的 outbound HTTP 获取 - internal/outbound/manager.go: 复用 metadata fetch 并保留 body-only 兼容 - internal/netutil/outbound_http_test.go: 覆盖 outbound 响应头保留 - internal/netutil/retry_downloader_test.go: 覆盖代理回退 metadata 透传 --- cmd/resin/app_runtime.go | 9 ++++-- internal/netutil/outbound_http.go | 27 +++++++++++++---- internal/netutil/outbound_http_test.go | 25 ++++++++++++++++ internal/netutil/retry_downloader_test.go | 35 +++++++++++++++++++++++ internal/outbound/manager.go | 21 ++++++++++++-- 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/cmd/resin/app_runtime.go b/cmd/resin/app_runtime.go index 1a779ba5..edb55c18 100644 --- a/cmd/resin/app_runtime.go +++ b/cmd/resin/app_runtime.go @@ -225,10 +225,15 @@ func (a *resinApp) wireRetryDownloader(retryDL *netutil.RetryDownloader) { } return res.NodeHash, nil } + proxyFetchMetadata := func(ctx context.Context, hash node.Hash, url string) (netutil.DownloadResponse, error) { + resp, _, err := a.topoRuntime.outboundMgr.FetchWithUserAgentMetadata(ctx, hash, url, currentDownloadUserAgent()) + return resp, err + } retryDL.ProxyFetch = func(ctx context.Context, hash node.Hash, url string) ([]byte, error) { - body, _, err := a.topoRuntime.outboundMgr.FetchWithUserAgent(ctx, hash, url, currentDownloadUserAgent()) - return body, err + resp, err := proxyFetchMetadata(ctx, hash, url) + return resp.Body, err } + retryDL.ProxyFetchMetadata = proxyFetchMetadata log.Println("RetryDownloader wiring complete") } diff --git a/internal/netutil/outbound_http.go b/internal/netutil/outbound_http.go index 27f4fb3a..c79289e2 100644 --- a/internal/netutil/outbound_http.go +++ b/internal/netutil/outbound_http.go @@ -44,8 +44,23 @@ func HTTPGetViaOutbound( url string, opts OutboundHTTPOptions, ) ([]byte, time.Duration, error) { + resp, latency, err := HTTPGetViaOutboundWithMetadata(ctx, outbound, url, opts) + if err != nil { + return nil, latency, err + } + return resp.Body, latency, nil +} + +// HTTPGetViaOutboundWithMetadata executes an HTTP GET through the provided +// outbound and preserves response headers. +func HTTPGetViaOutboundWithMetadata( + ctx context.Context, + outbound adapter.Outbound, + url string, + opts OutboundHTTPOptions, +) (DownloadResponse, time.Duration, error) { if outbound == nil { - return nil, 0, fmt.Errorf("outbound fetch: outbound is nil") + return DownloadResponse{}, 0, fmt.Errorf("outbound fetch: outbound is nil") } transport := &http.Transport{ @@ -70,7 +85,7 @@ func HTTPGetViaOutbound( req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, 0, err + return DownloadResponse{}, 0, err } userAgent := opts.UserAgent @@ -93,20 +108,20 @@ func HTTPGetViaOutbound( resp, err := client.Do(req) if err != nil { - return nil, 0, err + return DownloadResponse{}, 0, err } defer resp.Body.Close() if opts.RequireStatusOK && resp.StatusCode != http.StatusOK { - return nil, latency, fmt.Errorf("outbound fetch: unexpected status %d from %s", resp.StatusCode, url) + return DownloadResponse{}, latency, fmt.Errorf("outbound fetch: unexpected status %d from %s", resp.StatusCode, url) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, latency, err + return DownloadResponse{}, latency, err } - return body, latency, nil + return DownloadResponse{Body: body, Header: resp.Header.Clone()}, latency, nil } // connCloseHook wraps a net.Conn and calls onClose exactly once on Close. diff --git a/internal/netutil/outbound_http_test.go b/internal/netutil/outbound_http_test.go index 77b3fa59..c02b520c 100644 --- a/internal/netutil/outbound_http_test.go +++ b/internal/netutil/outbound_http_test.go @@ -57,6 +57,31 @@ func TestHTTPGetViaOutbound_AllowNon200(t *testing.T) { } } +func TestHTTPGetViaOutboundWithMetadata_PreservesHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=3") + _, _ = w.Write([]byte("subscription-body")) + })) + defer srv.Close() + + ob, err := (&testutil.StubOutboundBuilder{}).Build(nil) + if err != nil { + t.Fatalf("build outbound: %v", err) + } + resp, _, err := HTTPGetViaOutboundWithMetadata(context.Background(), ob, srv.URL, OutboundHTTPOptions{ + RequireStatusOK: true, + }) + if err != nil { + t.Fatalf("expected metadata response, got: %v", err) + } + if string(resp.Body) != "subscription-body" { + t.Fatalf("unexpected body %q", string(resp.Body)) + } + if got := resp.Header.Get("Subscription-Userinfo"); got != "upload=1; download=2; total=3" { + t.Fatalf("unexpected subscription userinfo %q", got) + } +} + func TestConnCloseHook_CloseIsIdempotentAndConcurrentSafe(t *testing.T) { client, server := net.Pipe() defer server.Close() diff --git a/internal/netutil/retry_downloader_test.go b/internal/netutil/retry_downloader_test.go index 68a739df..8e47e824 100644 --- a/internal/netutil/retry_downloader_test.go +++ b/internal/netutil/retry_downloader_test.go @@ -3,6 +3,7 @@ package netutil import ( "context" "errors" + "net/http" "strconv" "testing" "time" @@ -136,6 +137,40 @@ func TestRetryDownloader_RetryOnNetworkError(t *testing.T) { } } +func TestRetryDownloader_DownloadWithMetadataPreservesProxyHeaders(t *testing.T) { + var pickerCalls, proxyCalls int + + r := &RetryDownloader{ + Direct: downloaderFunc(func(_ context.Context, _ string) ([]byte, error) { + return nil, context.DeadlineExceeded + }), + NodePicker: func(_ string) (node.Hash, error) { + pickerCalls++ + return node.HashFromRawOptions([]byte(`{"id":"retry-node-metadata"}`)), nil + }, + ProxyFetchMetadata: func(_ context.Context, _ node.Hash, _ string) (DownloadResponse, error) { + proxyCalls++ + header := http.Header{} + header.Set("Subscription-Userinfo", "upload=1; download=2; total=3") + return DownloadResponse{Body: []byte("via-proxy"), Header: header}, nil + }, + } + + resp, err := r.DownloadWithMetadata(context.Background(), "https://example.com") + if err != nil { + t.Fatalf("expected proxy metadata success, got %v", err) + } + if string(resp.Body) != "via-proxy" { + t.Fatalf("unexpected body %q", string(resp.Body)) + } + if got := resp.Header.Get("Subscription-Userinfo"); got != "upload=1; download=2; total=3" { + t.Fatalf("unexpected subscription userinfo %q", got) + } + if pickerCalls != 1 || proxyCalls != 1 { + t.Fatalf("expected single successful retry, got picker=%d proxy=%d", pickerCalls, proxyCalls) + } +} + func TestRetryDownloader_NoRetryWhenContextDone(t *testing.T) { var pickerCalls int ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/outbound/manager.go b/internal/outbound/manager.go index bccc17e4..55a59ce4 100644 --- a/internal/outbound/manager.go +++ b/internal/outbound/manager.go @@ -134,15 +134,30 @@ func (m *OutboundManager) FetchWithUserAgent( url string, userAgent string, ) ([]byte, time.Duration, error) { + resp, latency, err := m.FetchWithUserAgentMetadata(ctx, hash, url, userAgent) + if err != nil { + return nil, latency, err + } + return resp.Body, latency, nil +} + +// FetchWithUserAgentMetadata executes HTTP request using the node's outbound, +// applies the given User-Agent if non-empty, and preserves response headers. +func (m *OutboundManager) FetchWithUserAgentMetadata( + ctx context.Context, + hash node.Hash, + url string, + userAgent string, +) (netutil.DownloadResponse, time.Duration, error) { entry, ok := m.pool.GetEntry(hash) if !ok { - return nil, 0, errors.New("node not found") + return netutil.DownloadResponse{}, 0, errors.New("node not found") } outboundPtr := entry.Outbound.Load() // *adapter.Outbound if outboundPtr == nil { - return nil, 0, ErrOutboundNotReady + return netutil.DownloadResponse{}, 0, ErrOutboundNotReady } - return netutil.HTTPGetViaOutbound(ctx, *outboundPtr, url, netutil.OutboundHTTPOptions{ + return netutil.HTTPGetViaOutboundWithMetadata(ctx, *outboundPtr, url, netutil.OutboundHTTPOptions{ RequireStatusOK: true, UserAgent: userAgent, }) From de805b1b70bc06d8464b2922e07cd5fadfe6935e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sat, 27 Jun 2026 01:55:49 +0800 Subject: [PATCH 16/25] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=B9=B3=E9=9D=A2=E7=A7=9F=E7=BA=A6=E4=B8=8E=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 控制平面租约: 调整租约服务逻辑并补充测试覆盖 - 路由矩阵: 扩展路由匹配逻辑与契约测试 - 节点界面: 新增节点详情抽屉并简化节点页面结构 - 平台租约界面: 增强租约展示字段与类型定义 --- internal/api/contract_test.go | 11 + internal/routing/router.go | 5 + internal/routing/router_matrix_test.go | 47 +++++ internal/service/control_plane_leases.go | 69 +++++-- internal/service/control_plane_leases_test.go | 22 +- webui/src/features/nodes/NodeDetailDrawer.tsx | 194 ++++++++++++++++++ webui/src/features/nodes/NodesPage.tsx | 192 ++--------------- .../platforms/PlatformLeasesPanel.tsx | 82 +++++++- webui/src/features/platforms/types.ts | 1 + 9 files changed, 419 insertions(+), 204 deletions(-) create mode 100644 webui/src/features/nodes/NodeDetailDrawer.tsx diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 8db14148..2bdf5d31 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -476,6 +476,14 @@ func TestAPIContract_GetLease_IncludesNodeTag(t *testing.T) { raw := json.RawMessage(`{"type":"ss","server":"198.51.100.50","port":443}`) cp.Pool.AddNodeFromSub(hash, raw, older.ID) cp.Pool.AddNodeFromSub(hash, raw, newer.ID) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing", hash.Hex()) + } + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 58 * time.Millisecond, + LastUpdated: time.Now(), + }) now := time.Now().UnixNano() cp.Router.RestoreLeases([]model.Lease{ @@ -505,6 +513,9 @@ func TestAPIContract_GetLease_IncludesNodeTag(t *testing.T) { if body["node_tag"] != "Z-Provider/aa" { t.Fatalf("node_tag: got %v, want %q", body["node_tag"], "Z-Provider/aa") } + if body["reference_latency_ms"] != float64(58) { + t.Fatalf("reference_latency_ms: got %v, want 58", body["reference_latency_ms"]) + } } func TestAPIContract_ListLeases_AccountFuzzySearch(t *testing.T) { diff --git a/internal/routing/router.go b/internal/routing/router.go index f31fc412..2b1fb6a6 100644 --- a/internal/routing/router.go +++ b/internal/routing/router.go @@ -445,6 +445,7 @@ type idleIPRouteCandidate struct { entry *node.NodeEntry latency time.Duration hasLatency bool + createdAt time.Time } func (r *Router) selectIdleIPRoute( @@ -472,6 +473,7 @@ func (r *Router) selectIdleIPRoute( entry: entry, latency: latency, hasLatency: hasLatency, + createdAt: entry.CreatedAt, } if current, ok := candidates[ip]; !ok || isBetterIdleIPCandidate(next, current) { candidates[ip] = next @@ -505,6 +507,9 @@ func isBetterIdleIPCandidate(next, current idleIPRouteCandidate) bool { if next.hasLatency && next.latency != current.latency { return next.latency < current.latency } + if !next.createdAt.Equal(current.createdAt) { + return next.createdAt.Before(current.createdAt) + } return next.hash.Hex() < current.hash.Hex() } diff --git a/internal/routing/router_matrix_test.go b/internal/routing/router_matrix_test.go index 4fc02292..655e2b48 100644 --- a/internal/routing/router_matrix_test.go +++ b/internal/routing/router_matrix_test.go @@ -246,6 +246,53 @@ func TestChooseSameIPRotationCandidate_PicksLowestLatency(t *testing.T) { } } +func TestSelectIdleIPRoutePrefersLatencyThenOlderCreatedAt(t *testing.T) { + pool := newRouterTestPool() + plat := platform.NewPlatform("plat-idle-ranking", "Plat-Idle-Ranking", nil, nil) + pool.addPlatform(plat) + + now := time.Now() + oldHash, oldEntry := newRoutableEntry(t, `{"id":"idle-old"}`, "198.51.100.81") + youngHash, youngEntry := newRoutableEntry(t, `{"id":"idle-young"}`, "198.51.100.82") + oldEntry.CreatedAt = now.Add(-time.Hour) + youngEntry.CreatedAt = now + + oldEntry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 80 * time.Millisecond, + LastUpdated: now, + }) + youngEntry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 20 * time.Millisecond, + LastUpdated: now, + }) + + pool.addEntry(oldHash, oldEntry) + pool.addEntry(youngHash, youngEntry) + pool.rebuildPlatformView(plat) + + router := newTestRouter(pool, nil) + stats := NewIPLoadStats() + got, _, ok := router.selectIdleIPRoute(plat, stats, "example.com") + if !ok { + t.Fatal("expected idle IP candidate") + } + if got != youngHash { + t.Fatalf("expected lower-latency young node %s, got %s", youngHash.Hex(), got.Hex()) + } + + youngEntry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 80 * time.Millisecond, + LastUpdated: now, + }) + got, _, ok = router.selectIdleIPRoute(plat, stats, "example.com") + if !ok { + t.Fatal("expected idle IP candidate after latency tie") + } + if got != oldHash { + t.Fatalf("expected older node %s on latency tie, got %s", oldHash.Hex(), got.Hex()) + } +} + func TestRouteRequest_SameIPRotationMissRecreatesLease(t *testing.T) { pool := newRouterTestPool() plat := platform.NewPlatform("plat-miss", "Plat-Miss", nil, nil) diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index a93a4510..1ebb52e8 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -16,26 +16,28 @@ import ( // LeaseResponse is the API response for a lease. type LeaseResponse struct { - PlatformID string `json:"platform_id"` - Account string `json:"account"` - NodeHash string `json:"node_hash"` - NodeTag string `json:"node_tag"` - EgressIP string `json:"egress_ip"` - CreatedAt string `json:"created_at"` - Expiry string `json:"expiry"` - LastAccessed string `json:"last_accessed"` + PlatformID string `json:"platform_id"` + Account string `json:"account"` + NodeHash string `json:"node_hash"` + NodeTag string `json:"node_tag"` + EgressIP string `json:"egress_ip"` + ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` + CreatedAt string `json:"created_at"` + Expiry string `json:"expiry"` + LastAccessed string `json:"last_accessed"` } -func leaseToResponse(lease model.Lease, nodeTag string) LeaseResponse { +func leaseToResponse(lease model.Lease, nodeTag string, referenceLatencyMs *float64) LeaseResponse { return LeaseResponse{ - PlatformID: lease.PlatformID, - Account: lease.Account, - NodeHash: lease.NodeHash, - NodeTag: nodeTag, - EgressIP: lease.EgressIP, - CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), - Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), - LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), + PlatformID: lease.PlatformID, + Account: lease.Account, + NodeHash: lease.NodeHash, + NodeTag: nodeTag, + EgressIP: lease.EgressIP, + ReferenceLatencyMs: referenceLatencyMs, + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), + Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), + LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), } } @@ -54,6 +56,33 @@ func (s *ControlPlaneService) resolveLeaseNodeTagFromHex(hashHex string) string return s.resolveLeaseNodeTag(hash) } +func (s *ControlPlaneService) resolveLeaseNodeReferenceLatency(hash node.Hash) *float64 { + if s == nil || s.Pool == nil || s.RuntimeCfg == nil { + return nil + } + entry, ok := s.Pool.GetEntry(hash) + if !ok { + return nil + } + cfg := s.RuntimeCfg.Load() + if cfg == nil { + return nil + } + avgMs, ok := node.AverageEWMAForDomainsMs(entry, cfg.LatencyAuthorities) + if !ok { + return nil + } + return &avgMs +} + +func (s *ControlPlaneService) resolveLeaseNodeReferenceLatencyFromHex(hashHex string) *float64 { + hash, err := node.ParseHex(hashHex) + if err != nil { + return nil + } + return s.resolveLeaseNodeReferenceLatency(hash) +} + // ListLeases returns all leases for a platform. func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, error) { if _, ok := s.Pool.GetPlatform(platformID); !ok { @@ -69,7 +98,7 @@ func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, er CreatedAtNs: lease.CreatedAtNs, ExpiryNs: lease.ExpiryNs, LastAccessedNs: lease.LastAccessedNs, - }, s.resolveLeaseNodeTag(lease.NodeHash))) + }, s.resolveLeaseNodeTag(lease.NodeHash), s.resolveLeaseNodeReferenceLatency(lease.NodeHash))) return true }) if result == nil { @@ -87,7 +116,7 @@ func (s *ControlPlaneService) GetLease(platformID, account string) (*LeaseRespon if ml == nil { return nil, notFound("lease not found") } - resp := leaseToResponse(*ml, s.resolveLeaseNodeTagFromHex(ml.NodeHash)) + resp := leaseToResponse(*ml, s.resolveLeaseNodeTagFromHex(ml.NodeHash), s.resolveLeaseNodeReferenceLatencyFromHex(ml.NodeHash)) return &resp, nil } @@ -202,7 +231,7 @@ func (s *ControlPlaneService) BindLease(platformID, account, nodeHashHex string) return nil, internal("bind lease", err) } - resp := leaseToResponse(ml, s.resolveLeaseNodeTag(h)) + resp := leaseToResponse(ml, s.resolveLeaseNodeTag(h), s.resolveLeaseNodeReferenceLatency(h)) return &resp, nil } diff --git a/internal/service/control_plane_leases_test.go b/internal/service/control_plane_leases_test.go index c58116e0..0901111e 100644 --- a/internal/service/control_plane_leases_test.go +++ b/internal/service/control_plane_leases_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "errors" "net/netip" + "sync/atomic" "testing" "time" + "github.com/Resinat/Resin/internal/config" "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/platform" @@ -33,11 +35,14 @@ func newLeaseInheritanceTestService() (*ControlPlaneService, *platform.Platform) Authorities: func() []string { return []string{"cloudflare.com"} }, P2CWindow: func() time.Duration { return 10 * time.Minute }, }) + runtimeCfg := &atomic.Pointer[config.RuntimeConfig]{} + runtimeCfg.Store(config.NewDefaultRuntimeConfig()) return &ControlPlaneService{ - Pool: pool, - SubMgr: subMgr, - Router: router, + Pool: pool, + SubMgr: subMgr, + Router: router, + RuntimeCfg: runtimeCfg, }, plat } @@ -193,6 +198,14 @@ func TestListLeases_NodeTagUsesEarliestSubscriptionThenMinTag(t *testing.T) { 60, []string{"0"}, ) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing", hash.Hex()) + } + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 42 * time.Millisecond, + LastUpdated: time.Now(), + }) now := time.Now().UnixNano() seedLease(t, cp, model.Lease{ @@ -215,6 +228,9 @@ func TestListLeases_NodeTagUsesEarliestSubscriptionThenMinTag(t *testing.T) { if leases[0].NodeTag != "OldSub/a" { t.Fatalf("node_tag: got %q, want %q", leases[0].NodeTag, "OldSub/a") } + if leases[0].ReferenceLatencyMs == nil || *leases[0].ReferenceLatencyMs != 42 { + t.Fatalf("reference_latency_ms: got %v, want 42", leases[0].ReferenceLatencyMs) + } } func TestInheritLeaseByPlatformName_Success(t *testing.T) { diff --git a/webui/src/features/nodes/NodeDetailDrawer.tsx b/webui/src/features/nodes/NodeDetailDrawer.tsx new file mode 100644 index 00000000..e08f783b --- /dev/null +++ b/webui/src/features/nodes/NodeDetailDrawer.tsx @@ -0,0 +1,194 @@ +import { X } from "lucide-react"; +import { useEffect } from "react"; +import { Badge } from "../../components/ui/Badge"; +import { Button } from "../../components/ui/Button"; +import { Card } from "../../components/ui/Card"; +import { useI18n } from "../../i18n"; +import { formatDateTime, formatRelativeTime } from "../../lib/time"; +import { + displayableReferenceLatencyMs, + firstTag, + formatLatency, + getNodeDisplayStatus, + referenceLatencyColor, + regionToFlag, +} from "./nodeFormat"; +import type { NodeSummary } from "./types"; + +type Props = { + node: NodeSummary; + onClose: () => void; + onProbeEgress: (hash: string) => void; + onProbeLatency: (hash: string) => void; + isEgressProbePending: (hash: string) => boolean; + isLatencyProbePending: (hash: string) => boolean; +}; + +export function NodeDetailDrawer({ + node, + onClose, + onProbeEgress, + onProbeLatency, + isEgressProbePending, + isLatencyProbePending, +}: Props) { + const { t } = useI18n(); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const title = firstTag(node); + const egressPending = isEgressProbePending(node.node_hash); + const latencyPending = isLatencyProbePending(node.node_hash); + + return ( +
+ event.stopPropagation()}> +
+
+

{title}

+

{node.node_hash}

+
+
+ +
+
+ +
+
+
+

{t("节点状态")}

+

{t("节点的网络出口、探测状态以及失败历史。")}

+
+ +
+
+ {t("创建时间")} +

{formatDateTime(node.created_at)}

+
+
+ {t("连续失败")} +

{!node.has_outbound ? "-" : node.failure_count}

+
+
+ {t("状态")} +
+ {(() => { + const status = getNodeDisplayStatus(node); + return ( +
+ {status === "manually_disabled" ? ( + {t("手动禁用")} + ) : status === "error" ? ( + {t("错误")} + ) : status === "disabled" ? ( + {t("禁用")} + ) : status === "pending_test" ? ( + {t("待测")} + ) : status === "circuit_open" ? ( + {t("熔断")} + ) : ( + {t("健康")} + )} + {(status === "circuit_open" || status === "pending_test") && node.circuit_open_since ? ( + + ({formatRelativeTime(node.circuit_open_since)}) + + ) : null} +
+ ); + })()} +
+
+
+ {t("出口 / 区域")} +

+ {node.egress_ip || "-"} / {regionToFlag(node.region)} +

+
+
+ {t("参考延迟")} + {(() => { + const latencyMs = displayableReferenceLatencyMs(node); + if (latencyMs === null) { + return

-

; + } + return

{formatLatency(latencyMs)}

; + })()} +
+
+ {t("上次探测")} +

{formatDateTime(node.last_latency_probe_attempt || "")}

+
+
+ + {node.last_error ? ( +
{t("最近错误:{{message}}", { message: node.last_error })}
+ ) : null} +
+ +
+
+

{t("节点别名")}

+
+ {!node.tags.length ? ( +

{t("无节点名信息")}

+ ) : ( +
+ {node.tags.map((tag) => ( +
+

{tag.tag}

+ {tag.subscription_name} + {tag.subscription_id} +
+ ))} +
+ )} +
+ +
+
+

{t("运维操作")}

+
+
+
+
+
{t("出口探测")}
+

{t("检查节点当前出口 IP。")}

+
+ +
+
+
+
{t("延迟探测")}
+

{t("检测节点网络延迟。")}

+
+ +
+
+
+
+
+
+ ); +} diff --git a/webui/src/features/nodes/NodesPage.tsx b/webui/src/features/nodes/NodesPage.tsx index e6ec64ed..7afc10eb 100644 --- a/webui/src/features/nodes/NodesPage.tsx +++ b/webui/src/features/nodes/NodesPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createColumnHelper } from "@tanstack/react-table"; -import { AlertTriangle, Ban, CircleCheck, Eraser, Globe, RefreshCw, Sparkles, X, Zap } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { AlertTriangle, Ban, CircleCheck, Eraser, Globe, RefreshCw, Sparkles, Zap } from "lucide-react"; +import { useMemo, useRef, useState, type CSSProperties } from "react"; import { useLocation } from "react-router-dom"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; @@ -19,6 +19,7 @@ import { listPlatforms } from "../platforms/api"; import type { Platform } from "../platforms/types"; import { listSubscriptions } from "../subscriptions/api"; import { disableNode, enableNode, getNode, listNodes, probeEgress, probeLatency } from "./api"; +import { NodeDetailDrawer } from "./NodeDetailDrawer"; import { NodeLeasesModal } from "./NodeLeasesModal"; import type { NodeSummary } from "./types"; import { getAllRegions } from "./regions"; @@ -214,7 +215,7 @@ function sortIndicator(active: boolean, order: SortOrder): string { } export function NodesPage() { - const { locale, t } = useI18n(); + const { t } = useI18n(); const location = useLocation(); const [draftFilters, setDraftFilters] = useState(() => draftFromQuery(location.search)); const [activeFilters, setActiveFilters] = useState(() => @@ -235,7 +236,7 @@ export function NodesPage() { const queryClient = useQueryClient(); - const allRegions = useMemo(() => getAllRegions(), [locale]); + const allRegions = getAllRegions(); const platformsQuery = useQuery({ queryKey: ["platforms", "all"], @@ -308,22 +309,6 @@ export function NodesPage() { const detailNode = nodeDetailQuery.data ?? selectedNode; const drawerVisible = drawerOpen && Boolean(detailNode); - useEffect(() => { - if (!drawerVisible) { - return; - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") { - return; - } - setDrawerOpen(false); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [drawerVisible]); - const openDrawer = (hash: string) => { setSelectedNodeHash(hash); setDrawerOpen(true); @@ -894,165 +879,14 @@ export function NodesPage() {
{drawerVisible && detailNode ? ( -
setDrawerOpen(false)} - > - event.stopPropagation()}> -
-
-

{firstTag(detailNode)}

-

{detailNode.node_hash}

-
-
- -
-
- -
-
-
-

{t("节点状态")}

-

{t("节点的网络出口、探测状态以及失败历史。")}

-
- -
-
- {t("创建时间")} -

{formatDateTime(detailNode.created_at)}

-
-
- {t("连续失败")} -

{!detailNode.has_outbound ? "-" : detailNode.failure_count}

-
-
- {t("状态")} -
- {(() => { - const status = getNodeDisplayStatus(detailNode); - return ( -
- {status === "manually_disabled" ? ( - {t("手动禁用")} - ) : status === "error" ? ( - {t("错误")} - ) : status === "disabled" ? ( - {t("禁用")} - ) : status === "pending_test" ? ( - {t("待测")} - ) : status === "circuit_open" ? ( - {t("熔断")} - ) : ( - {t("健康")} - )} - {(status === "circuit_open" || status === "pending_test") && detailNode.circuit_open_since ? ( - - ({formatRelativeTime(detailNode.circuit_open_since)}) - - ) : null} -
- ); - })()} -
-
-
- {t("出口 / 区域")} -

- {detailNode.egress_ip || "-"} / {regionToFlag(detailNode.region)} -

-
-
- {t("参考延迟")} - {(() => { - const latencyMs = displayableReferenceLatencyMs(detailNode); - if (latencyMs === null) { - return

-

; - } - return

{formatLatency(latencyMs)}

; - })()} -
-
- {t("上次探测")} -

{formatDateTime(detailNode.last_latency_probe_attempt || "")}

-
-
- - {detailNode.last_error ? ( -
{t("最近错误:{{message}}", { message: detailNode.last_error })}
- ) : null} -
- -
-
-

{t("节点别名")}

-
- {!detailNode.tags.length ? ( -

{t("无节点名信息")}

- ) : ( -
- {detailNode.tags.map((tag) => ( -
-

{tag.tag}

- {tag.subscription_name} - {tag.subscription_id} -
- ))} -
- )} -
- -
-
-

{t("运维操作")}

-
-
-
-
-
{t("出口探测")}
-

{t("检查节点当前出口 IP。")}

-
- -
-
-
-
{t("延迟探测")}
-

{t("检测节点网络延迟。")}

-
- -
-
-
-
-
-
+ setDrawerOpen(false)} + onProbeEgress={(hash) => void runProbeEgress(hash)} + onProbeLatency={(hash) => void runProbeLatency(hash)} + isEgressProbePending={(hash) => isProbePending(hash, "egress")} + isLatencyProbePending={(hash) => isProbePending(hash, "latency")} + /> ) : null} {leasesModalNodeHash diff --git a/webui/src/features/platforms/PlatformLeasesPanel.tsx b/webui/src/features/platforms/PlatformLeasesPanel.tsx index 0c656665..90d66bf7 100644 --- a/webui/src/features/platforms/PlatformLeasesPanel.tsx +++ b/webui/src/features/platforms/PlatformLeasesPanel.tsx @@ -10,7 +10,9 @@ import { Select } from "../../components/ui/Select"; import { useI18n } from "../../i18n"; import { formatApiErrorMessage } from "../../lib/error-message"; import { formatRelativeTime } from "../../lib/time"; -import { listNodes } from "../nodes/api"; +import { getNode, listNodes, probeEgress, probeLatency } from "../nodes/api"; +import { NodeDetailDrawer } from "../nodes/NodeDetailDrawer"; +import { formatLatency, referenceLatencyColor } from "../nodes/nodeFormat"; import { bindPlatformLease, deletePlatformLease, listPlatformLeases } from "./api"; import type { LeaseResponse, Platform } from "./types"; @@ -36,6 +38,7 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { const [bindOpen, setBindOpen] = useState(false); const [bindAccount, setBindAccount] = useState(""); const [selectedNodeHash, setSelectedNodeHash] = useState(""); + const [detailNodeHash, setDetailNodeHash] = useState(""); const [sortBy, setSortBy] = useState("account"); const [sortOrder, setSortOrder] = useState("asc"); @@ -66,6 +69,13 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { enabled: bindOpen, }); + const nodeDetailQuery = useQuery({ + queryKey: ["node", detailNodeHash], + queryFn: () => getNode(detailNodeHash), + enabled: Boolean(detailNodeHash), + refetchInterval: 30_000, + }); + const sortedNodes = (nodesQuery.data?.items ?? []).slice().sort((a, b) => { const aLat = a.reference_latency_ms; const bLat = b.reference_latency_ms; @@ -80,6 +90,14 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { await queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }); }; + const refreshDetailNode = async (hash: string) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["node", hash] }), + queryClient.invalidateQueries({ queryKey: ["platform-leases", platform.id] }), + queryClient.invalidateQueries({ queryKey: ["platform-nodes", platform.id] }), + ]); + }; + const deleteMutation = useMutation({ mutationFn: (account: string) => deletePlatformLease(platform.id, account), onSuccess: async (_, account) => { @@ -105,6 +123,35 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { }, }); + const probeEgressMutation = useMutation({ + mutationFn: (hash: string) => probeEgress(hash), + onSuccess: async (result, hash) => { + await refreshDetailNode(hash); + showToast( + "success", + t("出口探测完成:出口 IP={{ip}},区域={{region}},延迟={{latency}}", { + ip: result.egress_ip || "-", + region: result.region || "-", + latency: formatLatency(result.latency_ewma_ms), + }), + ); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const probeLatencyMutation = useMutation({ + mutationFn: (hash: string) => probeLatency(hash), + onSuccess: async (result, hash) => { + await refreshDetailNode(hash); + showToast("success", t("延迟探测完成:延迟={{latency}}", { latency: formatLatency(result.latency_ewma_ms) })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + const handleDelete = (account: string) => { const confirmed = window.confirm(t("确认解绑租约 {{account}}?", { account })); if (confirmed) { @@ -156,6 +203,21 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { columnHelper.accessor("egress_ip", { header: () => sortHeader(t("出口 IP"), "egress_ip"), }), + columnHelper.display({ + id: "reference_latency_ms", + header: t("参考延迟"), + cell: (info) => { + const latencyMs = info.row.original.reference_latency_ms; + if (typeof latencyMs !== "number") { + return "-"; + } + return ( + + {formatLatency(latencyMs)} + + ); + }, + }), columnHelper.accessor("created_at", { header: () => sortHeader(t("绑定时间"), "created_at"), cell: (info) => formatRelativeTime(info.getValue()), @@ -276,7 +338,12 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { ) : null} {leases.length ? ( - l.account} /> + l.account} + onRowClick={(lease) => setDetailNodeHash(lease.node_hash)} + /> ) : null} + + {nodeDetailQuery.data ? ( + setDetailNodeHash("")} + onProbeEgress={(hash) => probeEgressMutation.mutate(hash)} + onProbeLatency={(hash) => probeLatencyMutation.mutate(hash)} + isEgressProbePending={(hash) => probeEgressMutation.isPending && probeEgressMutation.variables === hash} + isLatencyProbePending={(hash) => probeLatencyMutation.isPending && probeLatencyMutation.variables === hash} + /> + ) : null} ); } diff --git a/webui/src/features/platforms/types.ts b/webui/src/features/platforms/types.ts index 6677a590..c3f632f9 100644 --- a/webui/src/features/platforms/types.ts +++ b/webui/src/features/platforms/types.ts @@ -54,6 +54,7 @@ export type LeaseResponse = { node_hash: string; node_tag: string; egress_ip: string; + reference_latency_ms?: number; created_at: string; expiry: string; last_accessed: string; From 821d1718ab0ef4e0fd170078888330014208b73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sat, 27 Jun 2026 02:27:00 +0800 Subject: [PATCH 17/25] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E5=8C=96?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA=E9=85=8D=E7=BD=AE=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/service/control_plane_data.go: 改为基于配置白名单动态导出和导入平台、订阅字段 - internal/service/control_plane_data_test.go: 增加动态字段导出、覆盖导入和 source_type 校验回归测试 --- internal/service/control_plane_data.go | 327 +++++++++++--------- internal/service/control_plane_data_test.go | 260 ++++++++++++++++ 2 files changed, 433 insertions(+), 154 deletions(-) create mode 100644 internal/service/control_plane_data_test.go diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go index dc295299..6190af90 100644 --- a/internal/service/control_plane_data.go +++ b/internal/service/control_plane_data.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Resinat/Resin/internal/platform" + "github.com/Resinat/Resin/internal/subscription" ) // ------------------------------------------------------------------ @@ -15,47 +16,27 @@ import ( const exportVersion = 1 -// ExportPlatformEntry is the portable representation of a platform. -type ExportPlatformEntry struct { - Name string `json:"name"` - StickyTTL string `json:"sticky_ttl"` - RegexFilters []string `json:"regex_filters"` - RegionFilters []string `json:"region_filters"` - AllocationPolicy string `json:"allocation_policy"` - ReverseProxyMissAction string `json:"reverse_proxy_miss_action"` - ReverseProxyEmptyAccountBehavior string `json:"reverse_proxy_empty_account_behavior"` - ReverseProxyFixedAccountHeader string `json:"reverse_proxy_fixed_account_header"` -} - -// ExportSubscriptionEntry is the portable representation of a subscription. -type ExportSubscriptionEntry struct { - Name string `json:"name"` - SourceType string `json:"source_type"` - URL string `json:"url"` - Content string `json:"content"` - UpdateInterval string `json:"update_interval"` - Enabled bool `json:"enabled"` - Ephemeral bool `json:"ephemeral"` - EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"` -} +// ExportEntry is a portable config object. Fields are filtered from the API +// response by the create/patch allowlists so export stays in sync with config. +type ExportEntry map[string]json.RawMessage // ExportPayload is the top-level JSON structure for data export/import. type ExportPayload struct { - Version int `json:"version"` - ExportedAt string `json:"exported_at"` - Platforms []ExportPlatformEntry `json:"platforms"` - Subscriptions []ExportSubscriptionEntry `json:"subscriptions"` + Version int `json:"version"` + ExportedAt string `json:"exported_at"` + Platforms []ExportEntry `json:"platforms"` + Subscriptions []ExportEntry `json:"subscriptions"` } // ImportResult summarises what happened during an import. type ImportResult struct { - PlatformsCreated int `json:"platforms_created"` - PlatformsSkipped int `json:"platforms_skipped"` - PlatformsOverwritten int `json:"platforms_overwritten"` - SubscriptionsCreated int `json:"subscriptions_created"` - SubscriptionsSkipped int `json:"subscriptions_skipped"` - SubscriptionsOverwritten int `json:"subscriptions_overwritten"` - Errors []string `json:"errors"` + PlatformsCreated int `json:"platforms_created"` + PlatformsSkipped int `json:"platforms_skipped"` + PlatformsOverwritten int `json:"platforms_overwritten"` + SubscriptionsCreated int `json:"subscriptions_created"` + SubscriptionsSkipped int `json:"subscriptions_skipped"` + SubscriptionsOverwritten int `json:"subscriptions_overwritten"` + Errors []string `json:"errors"` } // ------------------------------------------------------------------ @@ -65,48 +46,35 @@ type ImportResult struct { // ExportData builds an ExportPayload containing all user-created platforms // and all subscriptions. func (s *ControlPlaneService) ExportData() (*ExportPayload, error) { - // --- platforms (exclude Default) --- platforms, err := s.Engine.ListPlatforms() if err != nil { return nil, internal("list platforms for export", err) } - exportPlatforms := make([]ExportPlatformEntry, 0, len(platforms)) + exportPlatforms := make([]ExportEntry, 0, len(platforms)) for _, p := range platforms { if p.ID == platform.DefaultPlatformID { continue } - resp := platformToResponse(p) - exportPlatforms = append(exportPlatforms, ExportPlatformEntry{ - Name: resp.Name, - StickyTTL: resp.StickyTTL, - RegexFilters: resp.RegexFilters, - RegionFilters: resp.RegionFilters, - AllocationPolicy: resp.AllocationPolicy, - ReverseProxyMissAction: resp.ReverseProxyMissAction, - ReverseProxyEmptyAccountBehavior: resp.ReverseProxyEmptyAccountBehavior, - ReverseProxyFixedAccountHeader: resp.ReverseProxyFixedAccountHeader, - }) + entry, err := exportEntryFrom(platformToResponse(p), isPlatformConfigExportField) + if err != nil { + return nil, internal("encode platform for export", err) + } + exportPlatforms = append(exportPlatforms, entry) } - // --- subscriptions --- subs, err := s.ListSubscriptions(nil) if err != nil { return nil, internal("list subscriptions for export", err) } - exportSubs := make([]ExportSubscriptionEntry, 0, len(subs)) + exportSubs := make([]ExportEntry, 0, len(subs)) for _, sub := range subs { - exportSubs = append(exportSubs, ExportSubscriptionEntry{ - Name: sub.Name, - SourceType: sub.SourceType, - URL: sub.URL, - Content: sub.Content, - UpdateInterval: sub.UpdateInterval, - Enabled: sub.Enabled, - Ephemeral: sub.Ephemeral, - EphemeralNodeEvictDelay: sub.EphemeralNodeEvictDelay, - }) + entry, err := exportEntryFrom(sub, isSubscriptionConfigExportField) + if err != nil { + return nil, internal("encode subscription for export", err) + } + exportSubs = append(exportSubs, entry) } return &ExportPayload{ @@ -133,17 +101,13 @@ func (s *ControlPlaneService) ImportData(payload ExportPayload, strategy string) result := &ImportResult{Errors: []string{}} - // ----- import platforms ----- s.importPlatforms(payload.Platforms, strategy, result) - - // ----- import subscriptions ----- s.importSubscriptions(payload.Subscriptions, strategy, result) return result, nil } -func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, strategy string, result *ImportResult) { - // Build existing name→id lookup. +func (s *ControlPlaneService) importPlatforms(entries []ExportEntry, strategy string, result *ImportResult) { existing, err := s.Engine.ListPlatforms() if err != nil { result.Errors = append(result.Errors, "failed to list existing platforms: "+err.Error()) @@ -154,11 +118,13 @@ func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, str nameToID[p.Name] = p.ID } - // Detect duplicates inside the import payload itself. seen := make(map[string]bool, len(entries)) - for i, entry := range entries { - name := strings.TrimSpace(entry.Name) + name, err := requiredEntryString(entry, "name") + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: %v, skipped", i, err)) + continue + } if name == "" { result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: name is empty, skipped", i)) continue @@ -169,6 +135,7 @@ func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, str } seen[name] = true + configEntry := filterEntry(entry, isPlatformConfigExportField) existingID, exists := nameToID[name] if exists && strategy == "skip" { result.PlatformsSkipped++ @@ -176,9 +143,11 @@ func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, str } if exists && strategy == "overwrite" { - // Overwrite: build a patch JSON and call UpdatePlatform. - patch := buildPlatformPatch(entry) - patchJSON, _ := json.Marshal(patch) + patchJSON, err := marshalEntry(configEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: encode patch failed: %v", name, err)) + continue + } if _, err := s.UpdatePlatform(existingID, patchJSON); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("platform %q: overwrite failed: %v", name, err)) continue @@ -187,8 +156,11 @@ func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, str continue } - // Create new platform. - req := buildCreatePlatformRequest(entry) + req, err := decodeCreatePlatformRequest(configEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: decode failed: %v", name, err)) + continue + } if _, err := s.CreatePlatform(req); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("platform %q: create failed: %v", name, err)) continue @@ -197,8 +169,7 @@ func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, str } } -func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEntry, strategy string, result *ImportResult) { - // Build existing name→id and url→id lookup tables. +func (s *ControlPlaneService) importSubscriptions(entries []ExportEntry, strategy string, result *ImportResult) { existingSubs, err := s.ListSubscriptions(nil) if err != nil { result.Errors = append(result.Errors, "failed to list existing subscriptions: "+err.Error()) @@ -206,46 +177,65 @@ func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEn } nameToID := make(map[string]string, len(existingSubs)) urlToID := make(map[string]string, len(existingSubs)) + idToSourceType := make(map[string]string, len(existingSubs)) for _, sub := range existingSubs { nameToID[sub.Name] = sub.ID + idToSourceType[sub.ID] = sub.SourceType if sub.URL != "" { urlToID[sub.URL] = sub.ID } } - // Detect duplicates inside the import payload itself. seenName := make(map[string]bool, len(entries)) seenURL := make(map[string]bool, len(entries)) - for i, entry := range entries { - name := strings.TrimSpace(entry.Name) + name, err := requiredEntryString(entry, "name") + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: %v, skipped", i, err)) + continue + } if name == "" { result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: name is empty, skipped", i)) continue } - - // Internal dedup by name. if seenName[name] { result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate name %q in import payload, skipped", i, name)) continue } seenName[name] = true - // Internal dedup by URL for remote subs. - url := strings.TrimSpace(entry.URL) - if entry.SourceType == "remote" && url != "" { - if seenURL[url] { - result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate url %q in import payload, skipped", i, url)) + if _, exists := nameToID[name]; exists && strategy == "skip" { + result.SubscriptionsSkipped++ + continue + } + + sourceType, err := entrySourceType(entry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: %v, skipped", i, err)) + continue + } + + url := "" + if sourceType == subscription.SourceTypeRemote { + url, err = optionalEntryString(entry, "url") + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: %v, skipped", i, err)) continue } - seenURL[url] = true + url = strings.TrimSpace(url) + if url != "" { + if seenURL[url] { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate url %q in import payload, skipped", i, url)) + continue + } + seenURL[url] = true + } } - // Match against existing: first by name, then by URL. existingID := "" if id, ok := nameToID[name]; ok { existingID = id - } else if entry.SourceType == "remote" && url != "" { + } else if sourceType == subscription.SourceTypeRemote && url != "" { if id, ok := urlToID[url]; ok { existingID = id } @@ -256,9 +246,20 @@ func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEn continue } + configEntry := filterEntry(entry, isSubscriptionConfigExportField) if existingID != "" && strategy == "overwrite" { - patch := buildSubscriptionPatch(entry) - patchJSON, _ := json.Marshal(patch) + if existingSourceType := idToSourceType[existingID]; existingSourceType != "" && existingSourceType != sourceType { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: source_type mismatch (%s != %s), skipped", name, sourceType, existingSourceType)) + continue + } + patchEntry := filterEntry(configEntry, func(key string) bool { + return key != "source_type" + }) + patchJSON, err := marshalEntry(patchEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: encode patch failed: %v", name, err)) + continue + } if _, err := s.UpdateSubscription(existingID, patchJSON); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: overwrite failed: %v", name, err)) continue @@ -267,8 +268,11 @@ func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEn continue } - // Create new subscription. - req := buildCreateSubscriptionRequest(entry) + req, err := decodeCreateSubscriptionRequest(configEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: decode failed: %v", name, err)) + continue + } if _, err := s.CreateSubscription(req); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: create failed: %v", name, err)) continue @@ -277,84 +281,99 @@ func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEn } } -// ------------------------------------------------------------------ -// helpers: build request structs from export entries -// ------------------------------------------------------------------ - -func buildCreatePlatformRequest(e ExportPlatformEntry) CreatePlatformRequest { - name := strings.TrimSpace(e.Name) - return CreatePlatformRequest{ - Name: &name, - StickyTTL: strPtr(e.StickyTTL), - RegexFilters: e.RegexFilters, - RegionFilters: e.RegionFilters, - AllocationPolicy: strPtr(e.AllocationPolicy), - ReverseProxyMissAction: strPtr(e.ReverseProxyMissAction), - ReverseProxyEmptyAccountBehavior: strPtr(e.ReverseProxyEmptyAccountBehavior), - ReverseProxyFixedAccountHeader: strPtr(e.ReverseProxyFixedAccountHeader), +func exportEntryFrom(value any, allow func(string) bool) (ExportEntry, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + return filterEntry(object, allow), nil } -func buildPlatformPatch(e ExportPlatformEntry) map[string]any { - regexFilters := e.RegexFilters - if regexFilters == nil { - regexFilters = []string{} +func filterEntry(entry ExportEntry, allow func(string) bool) ExportEntry { + out := make(ExportEntry, len(entry)) + for key, raw := range entry { + if allow(key) { + out[key] = raw + } } - regionFilters := e.RegionFilters - if regionFilters == nil { - regionFilters = []string{} + return out +} + +func marshalEntry(entry ExportEntry) (json.RawMessage, error) { + data, err := json.Marshal(entry) + if err != nil { + return nil, err } - patch := map[string]any{ - "sticky_ttl": e.StickyTTL, - "regex_filters": regexFilters, - "region_filters": regionFilters, - "allocation_policy": e.AllocationPolicy, - "reverse_proxy_miss_action": e.ReverseProxyMissAction, - "reverse_proxy_empty_account_behavior": e.ReverseProxyEmptyAccountBehavior, - "reverse_proxy_fixed_account_header": e.ReverseProxyFixedAccountHeader, + return data, nil +} + +func isPlatformConfigExportField(key string) bool { + return platformPatchAllowedFields[key] +} + +func isSubscriptionConfigExportField(key string) bool { + return subscriptionPatchAllowedFields[key] || key == "source_type" +} + +func requiredEntryString(entry ExportEntry, field string) (string, error) { + value, err := optionalEntryString(entry, field) + if err != nil { + return "", err } - return patch + return strings.TrimSpace(value), nil } -func buildCreateSubscriptionRequest(e ExportSubscriptionEntry) CreateSubscriptionRequest { - name := strings.TrimSpace(e.Name) - sourceType := e.SourceType - url := strings.TrimSpace(e.URL) - content := e.Content - enabled := e.Enabled - ephemeral := e.Ephemeral - return CreateSubscriptionRequest{ - Name: &name, - SourceType: &sourceType, - URL: &url, - Content: &content, - UpdateInterval: strPtr(e.UpdateInterval), - Enabled: &enabled, - Ephemeral: &ephemeral, - EphemeralNodeEvictDelay: strPtr(e.EphemeralNodeEvictDelay), +func optionalEntryString(entry ExportEntry, field string) (string, error) { + raw, ok := entry[field] + if !ok { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("%s: must be a string", field) } + return value, nil } -func buildSubscriptionPatch(e ExportSubscriptionEntry) map[string]any { - patch := map[string]any{ - "name": strings.TrimSpace(e.Name), - "update_interval": e.UpdateInterval, - "enabled": e.Enabled, - "ephemeral": e.Ephemeral, - "ephemeral_node_evict_delay": e.EphemeralNodeEvictDelay, +func entrySourceType(entry ExportEntry) (string, error) { + sourceType, err := optionalEntryString(entry, "source_type") + if err != nil { + return "", err } - if e.SourceType == "remote" { - patch["url"] = strings.TrimSpace(e.URL) + sourceType = strings.ToLower(strings.TrimSpace(sourceType)) + if sourceType == "" { + return subscription.SourceTypeRemote, nil } - if e.SourceType == "local" { - patch["content"] = e.Content + if sourceType != subscription.SourceTypeRemote && sourceType != subscription.SourceTypeLocal { + return "", fmt.Errorf("source_type: must be remote or local") } - return patch + return sourceType, nil } -func strPtr(s string) *string { - if s == "" { - return nil +func decodeCreatePlatformRequest(entry ExportEntry) (CreatePlatformRequest, error) { + data, err := marshalEntry(entry) + if err != nil { + return CreatePlatformRequest{}, err + } + var req CreatePlatformRequest + if err := json.Unmarshal(data, &req); err != nil { + return CreatePlatformRequest{}, err + } + return req, nil +} + +func decodeCreateSubscriptionRequest(entry ExportEntry) (CreateSubscriptionRequest, error) { + data, err := marshalEntry(entry) + if err != nil { + return CreateSubscriptionRequest{}, err + } + var req CreateSubscriptionRequest + if err := json.Unmarshal(data, &req); err != nil { + return CreateSubscriptionRequest{}, err } - return &s + return req, nil } diff --git a/internal/service/control_plane_data_test.go b/internal/service/control_plane_data_test.go new file mode 100644 index 00000000..98243bd4 --- /dev/null +++ b/internal/service/control_plane_data_test.go @@ -0,0 +1,260 @@ +package service + +import ( + "encoding/json" + "net/netip" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Resinat/Resin/internal/config" + "github.com/Resinat/Resin/internal/state" + "github.com/Resinat/Resin/internal/topology" +) + +func newDataTestControlPlane(t *testing.T) *ControlPlaneService { + t.Helper() + + dir := t.TempDir() + engine, closer, err := state.PersistenceBootstrap( + filepath.Join(dir, "state"), + filepath.Join(dir, "cache"), + ) + if err != nil { + t.Fatalf("PersistenceBootstrap: %v", err) + } + t.Cleanup(func() { + _ = closer.Close() + }) + + subMgr := topology.NewSubscriptionManager() + pool := topology.NewGlobalNodePool(topology.PoolConfig{ + SubLookup: subMgr.Lookup, + GeoLookup: func(netip.Addr) string { return "us" }, + MaxLatencyTableEntries: 16, + MaxConsecutiveFailures: func() int { return 3 }, + LatencyDecayWindow: func() time.Duration { return 10 * time.Minute }, + }) + + return &ControlPlaneService{ + Engine: engine, + Pool: pool, + SubMgr: subMgr, + Scheduler: topology.NewSubscriptionScheduler(topology.SchedulerConfig{ + SubManager: subMgr, + Pool: pool, + Fetcher: func(string) ([]byte, error) { return []byte("[]"), nil }, + }), + EnvCfg: &config.EnvConfig{ + DefaultPlatformStickyTTL: 30 * time.Minute, + DefaultPlatformRegexFilters: []string{}, + DefaultPlatformRegionFilters: []string{}, + DefaultPlatformReverseProxyMissAction: "TREAT_AS_EMPTY", + DefaultPlatformAllocationPolicy: "BALANCED", + }, + } +} + +func exportTestEntry(t *testing.T, values map[string]any) ExportEntry { + t.Helper() + + entry := make(ExportEntry, len(values)) + for key, value := range values { + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", key, err) + } + entry[key] = data + } + return entry +} + +func entryStringValue(t *testing.T, entry ExportEntry, key string) string { + t.Helper() + + raw, ok := entry[key] + if !ok { + t.Fatalf("missing key %q in entry %v", key, entry) + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("unmarshal %s: %v", key, err) + } + return value +} + +func entryBoolValue(t *testing.T, entry ExportEntry, key string) bool { + t.Helper() + + raw, ok := entry[key] + if !ok { + t.Fatalf("missing key %q in entry %v", key, entry) + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("unmarshal %s: %v", key, err) + } + return value +} + +func TestExportData_UsesConfigFieldAllowlists(t *testing.T) { + cp := newDataTestControlPlane(t) + + platformName := "export-platform" + disablePassiveBreaker := true + if _, err := cp.CreatePlatform(CreatePlatformRequest{ + Name: &platformName, + PassiveCircuitBreakerDisabled: &disablePassiveBreaker, + }); err != nil { + t.Fatalf("CreatePlatform: %v", err) + } + + subName := "export-subscription" + subURL := "https://example.com/sub" + incrementalAliveNodes := true + if _, err := cp.CreateSubscription(CreateSubscriptionRequest{ + Name: &subName, + URL: &subURL, + IncrementalAliveNodes: &incrementalAliveNodes, + }); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + payload, err := cp.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if len(payload.Platforms) != 1 { + t.Fatalf("exported platforms = %d, want 1", len(payload.Platforms)) + } + if len(payload.Subscriptions) != 1 { + t.Fatalf("exported subscriptions = %d, want 1", len(payload.Subscriptions)) + } + + platformEntry := payload.Platforms[0] + for key := range platformPatchAllowedFields { + if _, ok := platformEntry[key]; !ok { + t.Fatalf("platform export missing allowed field %q", key) + } + } + for _, key := range []string{"id", "routable_node_count", "updated_at"} { + if _, ok := platformEntry[key]; ok { + t.Fatalf("platform export includes read-only field %q", key) + } + } + if !entryBoolValue(t, platformEntry, "passive_circuit_breaker_disabled") { + t.Fatal("expected passive_circuit_breaker_disabled to round-trip true") + } + + subEntry := payload.Subscriptions[0] + for key := range subscriptionPatchAllowedFields { + if _, ok := subEntry[key]; !ok { + t.Fatalf("subscription export missing allowed field %q", key) + } + } + if _, ok := subEntry["source_type"]; !ok { + t.Fatal("subscription export missing source_type") + } + for _, key := range []string{"id", "node_count", "healthy_node_count", "created_at", "last_checked", "last_updated", "last_error", "usage"} { + if _, ok := subEntry[key]; ok { + t.Fatalf("subscription export includes read-only field %q", key) + } + } + if !entryBoolValue(t, subEntry, "incremental_alive_nodes") { + t.Fatal("expected incremental_alive_nodes to round-trip true") + } +} + +func TestImportData_OverwriteDynamicConfigFields(t *testing.T) { + cp := newDataTestControlPlane(t) + + platformName := "overwrite-platform" + if _, err := cp.CreatePlatform(CreatePlatformRequest{Name: &platformName}); err != nil { + t.Fatalf("CreatePlatform: %v", err) + } + subName := "overwrite-subscription" + subURL := "https://example.com/overwrite-sub" + if _, err := cp.CreateSubscription(CreateSubscriptionRequest{Name: &subName, URL: &subURL}); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + payload := ExportPayload{ + Platforms: []ExportEntry{ + exportTestEntry(t, map[string]any{ + "name": platformName, + "passive_circuit_breaker_disabled": true, + }), + }, + Subscriptions: []ExportEntry{ + exportTestEntry(t, map[string]any{ + "name": subName, + "source_type": "remote", + "incremental_alive_nodes": true, + }), + }, + } + result, err := cp.ImportData(payload, "overwrite") + if err != nil { + t.Fatalf("ImportData: %v", err) + } + if len(result.Errors) != 0 { + t.Fatalf("ImportData errors = %v", result.Errors) + } + if result.PlatformsOverwritten != 1 || result.SubscriptionsOverwritten != 1 { + t.Fatalf("overwrite counts = %+v, want 1 platform and 1 subscription", result) + } + + exported, err := cp.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if !entryBoolValue(t, exported.Platforms[0], "passive_circuit_breaker_disabled") { + t.Fatal("expected platform passive circuit breaker setting to be overwritten") + } + if !entryBoolValue(t, exported.Subscriptions[0], "incremental_alive_nodes") { + t.Fatal("expected subscription incremental mode to be overwritten") + } +} + +func TestImportData_OverwriteRejectsSubscriptionSourceTypeChange(t *testing.T) { + cp := newDataTestControlPlane(t) + + subName := "source-mismatch" + sourceType := "local" + content := "1.2.3.4:8080" + if _, err := cp.CreateSubscription(CreateSubscriptionRequest{ + Name: &subName, + SourceType: &sourceType, + Content: &content, + }); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + result, err := cp.ImportData(ExportPayload{ + Subscriptions: []ExportEntry{ + exportTestEntry(t, map[string]any{ + "name": subName, + "source_type": "remote", + "url": "https://example.com/remote-sub", + }), + }, + }, "overwrite") + if err != nil { + t.Fatalf("ImportData: %v", err) + } + if result.SubscriptionsOverwritten != 0 { + t.Fatalf("subscriptions_overwritten = %d, want 0", result.SubscriptionsOverwritten) + } + if len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "source_type mismatch") { + t.Fatalf("errors = %v, want source_type mismatch", result.Errors) + } + + exported, err := cp.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if got := entryStringValue(t, exported.Subscriptions[0], "source_type"); got != "local" { + t.Fatalf("source_type = %q, want local", got) + } +} From 7c24e7c807f471e69614304470c4b8d11cfdbe10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sat, 27 Jun 2026 02:54:08 +0800 Subject: [PATCH 18/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A7=9F?= =?UTF-8?q?=E7=BA=A6=E8=AF=A6=E6=83=85=E6=8A=BD=E5=B1=89=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - webui/src/features/nodes/NodeDetailDrawer.tsx: 使用 Portal 将节点详情抽屉挂载到 body,避免在平台租约页被卡片定位上下文撑开页面 --- webui/src/features/nodes/NodeDetailDrawer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webui/src/features/nodes/NodeDetailDrawer.tsx b/webui/src/features/nodes/NodeDetailDrawer.tsx index e08f783b..6b336155 100644 --- a/webui/src/features/nodes/NodeDetailDrawer.tsx +++ b/webui/src/features/nodes/NodeDetailDrawer.tsx @@ -1,5 +1,6 @@ import { X } from "lucide-react"; import { useEffect } from "react"; +import { createPortal } from "react-dom"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; import { Card } from "../../components/ui/Card"; @@ -49,7 +50,7 @@ export function NodeDetailDrawer({ const egressPending = isEgressProbePending(node.node_hash); const latencyPending = isLatencyProbePending(node.node_hash); - return ( + return createPortal(
- + , + document.body, ); } From 081b37b04db92be33b77f07710aeb6ef4b00c4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sat, 27 Jun 2026 11:52:48 +0800 Subject: [PATCH 19/25] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E8=AE=A2=E9=98=85=E7=AE=A1=E7=90=86=E4=B8=8E=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 节点管理: 增加节点接口能力、前端交互和测试覆盖 - 订阅管理: 扩展订阅 API、类型、页面逻辑和接口测试 - 控制面服务: 补充节点控制逻辑与单元测试 - 仪表盘: 调整 KPI 请求情况展示宽度和布局 - 国际化与样式: 补充相关翻译和页面样式 --- internal/api/contract_test.go | 36 +++++- internal/api/handler_node.go | 15 +++ internal/api/handler_node_test.go | 54 +++++++++ internal/api/handler_subscription.go | 112 +++++++++++++++++- internal/api/handler_subscription_test.go | 42 +++++++ internal/api/server.go | 1 + internal/service/control_plane_nodes.go | 58 +++++++++ internal/service/control_plane_nodes_test.go | 73 ++++++++++++ .../src/features/dashboard/DashboardPage.tsx | 39 ++++-- webui/src/features/nodes/NodesPage.tsx | 60 +++++++++- webui/src/features/nodes/api.ts | 11 ++ .../platforms/PlatformAccessPanel.tsx | 33 +++++- .../subscriptions/SubscriptionPage.tsx | 49 +++++++- webui/src/features/subscriptions/api.ts | 47 +++++++- webui/src/features/subscriptions/types.ts | 14 +++ webui/src/i18n/translations.ts | 17 +++ webui/src/styles/theme.css | 27 ++++- 17 files changed, 650 insertions(+), 38 deletions(-) create mode 100644 internal/api/handler_subscription_test.go diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 2bdf5d31..a941a1db 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -826,13 +826,47 @@ func TestAPIContract_KeywordFilteringOnListEndpoints(t *testing.T) { if rec.Code != http.StatusCreated { t.Fatalf("create subscription banana status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) } + rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ + "name": "Aardvark Feed", + "url": "https://example.com/aardvark", + "enabled": false, + }, true) + if rec.Code != http.StatusCreated { + t.Fatalf("create subscription disabled status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/subscriptions?keyword=Feed&enabled=true", nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("list subscriptions default status sort status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body = decodeJSONMap(t, rec) + subItems, ok := body["items"].([]any) + if !ok { + t.Fatalf("subscription status sort items type: got %T", body["items"]) + } + if len(subItems) != 2 { + t.Fatalf("subscription enabled items len: got %d, want %d, body=%s", len(subItems), 2, rec.Body.String()) + } + if got := subItems[0].(map[string]any)["name"]; got != "Apple Feed" { + t.Fatalf("subscription status sort first: got %v, want %q", got, "Apple Feed") + } + if got := subItems[1].(map[string]any)["name"]; got != "Banana Feed" { + t.Fatalf("subscription status sort second: got %v, want %q", got, "Banana Feed") + } + summary, ok := body["summary"].(map[string]any) + if !ok { + t.Fatalf("subscription summary type: got %T", body["summary"]) + } + if summary["enabled_count"] != float64(2) || summary["disabled_count"] != float64(1) { + t.Fatalf("subscription summary counts: got enabled=%v disabled=%v, want 2/1", summary["enabled_count"], summary["disabled_count"]) + } rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/subscriptions?keyword=BANANA&sort_by=name&sort_order=asc", nil, true) if rec.Code != http.StatusOK { t.Fatalf("list subscriptions keyword status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } body = decodeJSONMap(t, rec) - subItems, ok := body["items"].([]any) + subItems, ok = body["items"].([]any) if !ok { t.Fatalf("subscription items type: got %T", body["items"]) } diff --git a/internal/api/handler_node.go b/internal/api/handler_node.go index dfec9a5f..63adebda 100644 --- a/internal/api/handler_node.go +++ b/internal/api/handler_node.go @@ -272,3 +272,18 @@ func HandleEnableNode(cp *service.ControlPlaneService) http.HandlerFunc { WriteJSON(w, http.StatusOK, result) } } + +// HandleCleanupNode returns a handler for POST /api/v1/nodes/{hash}/actions/cleanup. +// Cleaning a node evicts every subscription reference and releases every lease +// currently bound to it. +func HandleCleanupNode(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + result, err := cp.CleanupNode(hash) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, result) + } +} diff --git a/internal/api/handler_node_test.go b/internal/api/handler_node_test.go index 4540f3b0..f869d781 100644 --- a/internal/api/handler_node_test.go +++ b/internal/api/handler_node_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Resinat/Resin/internal/config" + "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/probe" "github.com/Resinat/Resin/internal/service" @@ -253,6 +254,59 @@ func TestHandleProbeEgress_ReturnsRegion(t *testing.T) { } } +func TestHandleCleanupNode_RemovesNodeAndLeases(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + + sub := subscription.NewSubscription("11111111-1111-1111-1111-111111111111", "sub-a", "https://example.com/a", true, false) + cp.SubMgr.Register(sub) + + raw := []byte(`{"type":"ss","server":"1.1.1.1","port":443}`) + hash := node.HashFromRawOptions(raw) + cp.Pool.AddNodeFromSub(hash, raw, sub.ID) + sub.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"tag"}}) + + now := time.Now().UnixNano() + if err := cp.Router.UpsertLease(model.Lease{ + PlatformID: "platform-a", + Account: "alice", + NodeHash: hash.Hex(), + EgressIP: "203.0.113.10", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }); err != nil { + t.Fatalf("upsert lease: %v", err) + } + + rec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/nodes/"+hash.Hex()+"/actions/cleanup", nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("cleanup status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + if body["evicted_subscription_count"] != float64(1) { + t.Fatalf("evicted_subscription_count: got %v, want 1", body["evicted_subscription_count"]) + } + if body["released_lease_count"] != float64(1) { + t.Fatalf("released_lease_count: got %v, want 1", body["released_lease_count"]) + } + if _, ok := cp.Pool.GetEntry(hash); ok { + t.Fatal("node should be removed from pool") + } + managed, ok := sub.ManagedNodes().LoadNode(hash) + if !ok || !managed.Evicted { + t.Fatalf("managed node evicted = %v, ok=%v; want true", managed.Evicted, ok) + } + + rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/nodes/not-hex/actions/cleanup", nil, true) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid hash status: got %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/nodes/"+hash.Hex()+"/actions/cleanup", nil, true) + if rec.Code != http.StatusNotFound { + t.Fatalf("missing node status: got %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) + } +} + func TestHandleListNodes_EnabledFilter(t *testing.T) { srv, cp, _ := newControlPlaneTestServer(t) diff --git a/internal/api/handler_subscription.go b/internal/api/handler_subscription.go index ba8b1b32..67c5d58c 100644 --- a/internal/api/handler_subscription.go +++ b/internal/api/handler_subscription.go @@ -2,11 +2,30 @@ package api import ( "net/http" + "slices" "strings" "github.com/Resinat/Resin/internal/service" ) +type subscriptionListSummary struct { + EnabledCount int `json:"enabled_count"` + DisabledCount int `json:"disabled_count"` + UsageUsedBytes int64 `json:"usage_used_bytes"` + UsageTotalBytes int64 `json:"usage_total_bytes"` + UsageRemainingBytes int64 `json:"usage_remaining_bytes"` + HealthyNodeCount int `json:"healthy_node_count"` + NodeCount int `json:"node_count"` +} + +type subscriptionListPageResponse struct { + Items []service.SubscriptionResponse `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Summary subscriptionListSummary `json:"summary"` +} + func subscriptionMatchesKeyword(s service.SubscriptionResponse, keyword string) bool { contains := func(v string) bool { return strings.Contains(strings.ToLower(v), keyword) @@ -29,6 +48,19 @@ func filterSubscriptionsByKeyword(subs []service.SubscriptionResponse, rawKeywor return filtered } +func filterSubscriptionsByEnabled(subs []service.SubscriptionResponse, enabled *bool) []service.SubscriptionResponse { + if enabled == nil { + return subs + } + filtered := make([]service.SubscriptionResponse, 0, len(subs)) + for _, sub := range subs { + if sub.Enabled == *enabled { + filtered = append(filtered, sub) + } + } + return filtered +} + func subscriptionSortKey(sortBy string, s service.SubscriptionResponse) string { switch sortBy { case "created_at": @@ -42,6 +74,66 @@ func subscriptionSortKey(sortBy string, s service.SubscriptionResponse) string { } } +func nonNegativeInt64(value int64) int64 { + if value < 0 { + return 0 + } + return value +} + +func summarizeSubscriptions(subs []service.SubscriptionResponse) subscriptionListSummary { + var summary subscriptionListSummary + for _, sub := range subs { + if sub.Enabled { + summary.EnabledCount++ + } else { + summary.DisabledCount++ + continue + } + summary.HealthyNodeCount += sub.HealthyNodeCount + summary.NodeCount += sub.NodeCount + if sub.Usage == nil { + continue + } + usedBytes := nonNegativeInt64(sub.Usage.UploadBytes) + nonNegativeInt64(sub.Usage.DownloadBytes) + summary.UsageUsedBytes += usedBytes + if sub.Usage.TotalBytes > 0 { + summary.UsageTotalBytes += sub.Usage.TotalBytes + remaining := sub.Usage.TotalBytes - usedBytes + if remaining > 0 { + summary.UsageRemainingBytes += remaining + } + } + } + return summary +} + +func compareSubscriptionsForList(a, b service.SubscriptionResponse, sorting Sorting) int { + if sorting.SortBy == "status" { + if a.Enabled != b.Enabled { + order := 1 + if a.Enabled { + order = -1 + } + return applySortOrder(order, sorting.SortOrder) + } + if order := strings.Compare(a.Name, b.Name); order != 0 { + return order + } + if order := strings.Compare(b.CreatedAt, a.CreatedAt); order != 0 { + return order + } + return strings.Compare(a.ID, b.ID) + } + + order := strings.Compare(subscriptionSortKey(sorting.SortBy, a), subscriptionSortKey(sorting.SortBy, b)) + order = applySortOrder(order, sorting.SortOrder) + if order != 0 { + return order + } + return strings.Compare(a.ID, b.ID) +} + // HandleListSubscriptions returns a handler for GET /api/v1/subscriptions. func HandleListSubscriptions(cp *service.ControlPlaneService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -49,32 +141,40 @@ func HandleListSubscriptions(cp *service.ControlPlaneService) http.HandlerFunc { if !ok { return } - subs, err := cp.ListSubscriptions(enabled) + subs, err := cp.ListSubscriptions(nil) if err != nil { writeServiceError(w, err) return } subs = filterSubscriptionsByKeyword(subs, r.URL.Query().Get("keyword")) + summary := summarizeSubscriptions(subs) + subs = filterSubscriptionsByEnabled(subs, enabled) sorting, ok := parseSortingOrWriteInvalid( w, r, - []string{"name", "created_at", "last_checked", "last_updated"}, - "created_at", + []string{"status", "name", "created_at", "last_checked", "last_updated"}, + "status", "asc", ) if !ok { return } - SortSlice(subs, sorting, func(s service.SubscriptionResponse) string { - return subscriptionSortKey(sorting.SortBy, s) + slices.SortStableFunc(subs, func(a, b service.SubscriptionResponse) int { + return compareSubscriptionsForList(a, b, sorting) }) pg, ok := parsePaginationOrWriteInvalid(w, r) if !ok { return } - WritePage(w, http.StatusOK, subs, pg) + WriteJSON(w, http.StatusOK, subscriptionListPageResponse{ + Items: PaginateSlice(subs, pg), + Total: len(subs), + Limit: pg.Limit, + Offset: pg.Offset, + Summary: summary, + }) } } diff --git a/internal/api/handler_subscription_test.go b/internal/api/handler_subscription_test.go new file mode 100644 index 00000000..ce59ac19 --- /dev/null +++ b/internal/api/handler_subscription_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "testing" + + "github.com/Resinat/Resin/internal/service" +) + +func TestSummarizeSubscriptions_OnlyEnabledSubscriptionsContributeUsageAndNodes(t *testing.T) { + summary := summarizeSubscriptions([]service.SubscriptionResponse{ + { + Enabled: true, + HealthyNodeCount: 2, + NodeCount: 3, + Usage: &service.SubscriptionUsageResponse{ + UploadBytes: 10, + DownloadBytes: 20, + TotalBytes: 100, + }, + }, + { + Enabled: false, + HealthyNodeCount: 5, + NodeCount: 8, + Usage: &service.SubscriptionUsageResponse{ + UploadBytes: 1000, + DownloadBytes: 2000, + TotalBytes: 9000, + }, + }, + }) + + if summary.EnabledCount != 1 || summary.DisabledCount != 1 { + t.Fatalf("counts = %d/%d, want 1/1", summary.EnabledCount, summary.DisabledCount) + } + if summary.HealthyNodeCount != 2 || summary.NodeCount != 3 { + t.Fatalf("nodes = %d/%d, want 2/3", summary.HealthyNodeCount, summary.NodeCount) + } + if summary.UsageUsedBytes != 30 || summary.UsageTotalBytes != 100 || summary.UsageRemainingBytes != 70 { + t.Fatalf("usage = used %d total %d remaining %d, want 30/100/70", summary.UsageUsedBytes, summary.UsageTotalBytes, summary.UsageRemainingBytes) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 4b3f4aac..6ce8e502 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -117,6 +117,7 @@ func NewServerWithAddress( authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-latency", HandleProbeLatency(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/disable", HandleDisableNode(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/enable", HandleEnableNode(cp)) + authed.Handle("POST /api/v1/nodes/{hash}/actions/cleanup", HandleCleanupNode(cp)) // GeoIP. authed.Handle("GET /api/v1/geoip/status", HandleGeoIPStatus(cp)) diff --git a/internal/service/control_plane_nodes.go b/internal/service/control_plane_nodes.go index 5997a666..ad21a4d8 100644 --- a/internal/service/control_plane_nodes.go +++ b/internal/service/control_plane_nodes.go @@ -290,6 +290,11 @@ type SetNodeManualDisableResult struct { ReleasedLeaseCount int `json:"released_lease_count"` } +type CleanupNodeResult struct { + EvictedSubscriptionCount int `json:"evicted_subscription_count"` + ReleasedLeaseCount int `json:"released_lease_count"` +} + // SetNodeManualDisable toggles the admin-controlled disable flag on a node. // When transitioning to disabled, all leases currently bound to the node are // immediately released so connected platforms reroute to other nodes. The @@ -320,3 +325,56 @@ func (s *ControlPlaneService) SetNodeManualDisable(hashStr string, disable bool) } return SetNodeManualDisableResult{ReleasedLeaseCount: released}, nil } + +func (s *ControlPlaneService) CleanupNode(hashStr string) (CleanupNodeResult, error) { + h, err := node.ParseHex(hashStr) + if err != nil { + return CleanupNodeResult{}, invalidArg("node_hash: invalid format") + } + entry, ok := s.Pool.GetEntry(h) + if !ok { + return CleanupNodeResult{}, notFound("node not found") + } + + evictedSubIDs := make([]string, 0, entry.SubscriptionCount()) + for _, subID := range entry.SubscriptionIDs() { + sub := s.SubMgr.Lookup(subID) + if sub == nil { + s.Pool.RemoveNodeFromSub(h, subID) + continue + } + + evicted := false + sub.WithOpLock(func() { + lockedSub := s.SubMgr.Lookup(subID) + if lockedSub == nil { + return + } + managed, ok := lockedSub.ManagedNodes().LoadNode(h) + if ok && !managed.Evicted { + managed.Evicted = true + lockedSub.ManagedNodes().StoreNode(h, managed) + evicted = true + } + s.Pool.RemoveNodeFromSub(h, subID) + }) + if evicted { + evictedSubIDs = append(evictedSubIDs, subID) + } + } + + if s.Engine != nil { + for _, subID := range evictedSubIDs { + s.Engine.MarkSubscriptionNode(subID, h.Hex()) + } + } + + released := 0 + if s.Router != nil { + released = s.Router.DeleteLeasesByNode(h) + } + return CleanupNodeResult{ + EvictedSubscriptionCount: len(evictedSubIDs), + ReleasedLeaseCount: released, + }, nil +} diff --git a/internal/service/control_plane_nodes_test.go b/internal/service/control_plane_nodes_test.go index 3280556f..fb38a66b 100644 --- a/internal/service/control_plane_nodes_test.go +++ b/internal/service/control_plane_nodes_test.go @@ -8,9 +8,11 @@ import ( "github.com/Resinat/Resin/internal/config" "github.com/Resinat/Resin/internal/geoip" + "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/platform" "github.com/Resinat/Resin/internal/probe" + "github.com/Resinat/Resin/internal/routing" "github.com/Resinat/Resin/internal/subscription" "github.com/Resinat/Resin/internal/testutil" "github.com/Resinat/Resin/internal/topology" @@ -523,3 +525,74 @@ func TestProbeEgress_ReturnsRegion(t *testing.T) { t.Fatalf("region: got %q, want %q", got.Region, "jp") } } + +func TestCleanupNode_EvictsAllSubscriptionsAndReleasesLeases(t *testing.T) { + subMgr := topology.NewSubscriptionManager() + pool := newNodeListTestPool(subMgr) + router := routing.NewRouter(routing.RouterConfig{Pool: pool}) + + subA := subscription.NewSubscription("sub-a", "sub-a", "https://example.com/a", true, false) + subB := subscription.NewSubscription("sub-b", "sub-b", "https://example.com/b", true, false) + subMgr.Register(subA) + subMgr.Register(subB) + + raw := []byte(`{"type":"ss","server":"1.1.1.1","port":443}`) + hash := node.HashFromRawOptions(raw) + pool.AddNodeFromSub(hash, raw, subA.ID) + pool.AddNodeFromSub(hash, raw, subB.ID) + subA.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"a"}}) + subB.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"b"}}) + + now := time.Now().UnixNano() + for _, account := range []string{"alice", "bob"} { + if err := router.UpsertLease(model.Lease{ + PlatformID: "platform-a", + Account: account, + NodeHash: hash.Hex(), + EgressIP: "203.0.113.10", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }); err != nil { + t.Fatalf("upsert lease %s: %v", account, err) + } + } + + cp := &ControlPlaneService{ + Pool: pool, + SubMgr: subMgr, + Router: router, + } + result, err := cp.CleanupNode(hash.Hex()) + if err != nil { + t.Fatalf("CleanupNode: %v", err) + } + if result.EvictedSubscriptionCount != 2 { + t.Fatalf("evicted_subscription_count = %d, want 2", result.EvictedSubscriptionCount) + } + if result.ReleasedLeaseCount != 2 { + t.Fatalf("released_lease_count = %d, want 2", result.ReleasedLeaseCount) + } + if _, ok := pool.GetEntry(hash); ok { + t.Fatal("node should be removed from pool") + } + for _, sub := range []*subscription.Subscription{subA, subB} { + managed, ok := sub.ManagedNodes().LoadNode(hash) + if !ok { + t.Fatalf("managed node missing for subscription %s", sub.ID) + } + if !managed.Evicted { + t.Fatalf("managed node for subscription %s should be evicted", sub.ID) + } + } + remaining := 0 + router.RangeAllLeases(func(_, _ string, lease routing.Lease) bool { + if lease.NodeHash == hash { + remaining++ + } + return true + }) + if remaining != 0 { + t.Fatalf("remaining leases for node = %d, want 0", remaining) + } +} diff --git a/webui/src/features/dashboard/DashboardPage.tsx b/webui/src/features/dashboard/DashboardPage.tsx index c5e9c276..45ab3144 100644 --- a/webui/src/features/dashboard/DashboardPage.tsx +++ b/webui/src/features/dashboard/DashboardPage.tsx @@ -712,7 +712,7 @@ function historyRefreshMsFromBuckets(bucketSeconds: Array): export function DashboardPage() { const { t } = useI18n(); - const [rangeKey, setRangeKey] = useState("6h"); + const [rangeKey, setRangeKey] = useState("24h"); const queryClient = useQueryClient(); const globalRealtimeQuery = useQuery({ @@ -873,7 +873,9 @@ export function DashboardPage() { const latestConnections = latestValue(connectionsInbound) + latestValue(connectionsOutbound); const latestLeases = latestValue(leasesValues); - const totalTrafficBytes = sum(trafficIngress) + sum(trafficEgress); + const totalIngressBytes = sum(trafficIngress); + const totalEgressBytes = sum(trafficEgress); + const totalTrafficBytes = totalIngressBytes + totalEgressBytes; const totalRequests = sum(requestTotals); const successRequests = requestItems.reduce((acc, item) => acc + item.success_requests, 0); @@ -912,7 +914,20 @@ export function DashboardPage() { ) : null}
- + +
+ +
+
+

{t("实时连接数")}

+

{formatCount(latestConnections)}

+

+ {t("入站")} {formatCount(latestValue(connectionsInbound))} · {t("出站")} {formatCount(latestValue(connectionsOutbound))} +

+
+
+ +
@@ -925,20 +940,20 @@ export function DashboardPage() {
- -
- + +
+
-

{t("实时连接数")}

-

{formatCount(latestConnections)}

+

{t("请求情况")}

+

{formatBytes(totalTrafficBytes)}

- {t("入站")} {formatCount(latestValue(connectionsInbound))} · {t("出站")} {formatCount(latestValue(connectionsOutbound))} + {t("请求")} {formatCount(totalRequests)} · {t("成功")} {formatCount(successRequests)} · {t("下载")} {formatBytes(totalIngressBytes)} · {t("上传")} {formatBytes(totalEgressBytes)}

- +
@@ -954,14 +969,14 @@ export function DashboardPage() {
- +

{t("活跃租约数")}

{formatCount(latestLeases)}

-

{t("来自所有平台租约总和")}

+

{t("所有平台")}

diff --git a/webui/src/features/nodes/NodesPage.tsx b/webui/src/features/nodes/NodesPage.tsx index 7afc10eb..00b130c0 100644 --- a/webui/src/features/nodes/NodesPage.tsx +++ b/webui/src/features/nodes/NodesPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createColumnHelper } from "@tanstack/react-table"; -import { AlertTriangle, Ban, CircleCheck, Eraser, Globe, RefreshCw, Sparkles, Zap } from "lucide-react"; +import { AlertTriangle, Ban, CircleCheck, Eraser, Globe, RefreshCw, Sparkles, Trash2, Zap } from "lucide-react"; import { useMemo, useRef, useState, type CSSProperties } from "react"; import { useLocation } from "react-router-dom"; import { Badge } from "../../components/ui/Badge"; @@ -18,7 +18,7 @@ import { formatDateTime, formatRelativeTime } from "../../lib/time"; import { listPlatforms } from "../platforms/api"; import type { Platform } from "../platforms/types"; import { listSubscriptions } from "../subscriptions/api"; -import { disableNode, enableNode, getNode, listNodes, probeEgress, probeLatency } from "./api"; +import { cleanupNode, disableNode, enableNode, getNode, listNodes, probeEgress, probeLatency } from "./api"; import { NodeDetailDrawer } from "./NodeDetailDrawer"; import { NodeLeasesModal } from "./NodeLeasesModal"; import type { NodeSummary } from "./types"; @@ -381,6 +381,30 @@ export function NodesPage() { }, }); + const cleanupNodeMutation = useMutation({ + mutationFn: async (hash: string) => cleanupNode(hash), + onSuccess: async (result, hash) => { + if (selectedHash === hash) { + setDrawerOpen(false); + setSelectedNodeHash(""); + } + if (leasesModalNodeHash === hash) { + setLeasesModalNodeHash(null); + } + await refreshNodes(); + const count = result.released_lease_count ?? 0; + if (count > 0) { + showToast("success", t("节点已清理,解除 {{count}} 个绑定", { count })); + } else { + showToast("success", t("节点已清理")); + } + }, + onError: async (error) => { + await refreshNodes(); + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + const runDisableNode = async (hash: string) => { try { await disableNodeMutation.mutateAsync(hash); @@ -397,10 +421,27 @@ export function NodesPage() { } }; + const runCleanupNode = async (node: NodeSummary) => { + const confirmed = window.confirm( + t("确认清理节点 {{name}}?该节点会从节点池移除,并解除相关租约。", { name: firstTag(node) }) + ); + if (!confirmed) { + return; + } + try { + await cleanupNodeMutation.mutateAsync(node.node_hash); + } catch { + // Mutation callbacks already surface the failure to the user. + } + }; + const isDisableActionPending = (hash: string): boolean => (disableNodeMutation.isPending && disableNodeMutation.variables === hash) || (enableNodeMutation.isPending && enableNodeMutation.variables === hash); + const isCleanupActionPending = (hash: string): boolean => + cleanupNodeMutation.isPending && cleanupNodeMutation.variables === hash; + const markProbePending = (hash: string, action: ProbeAction): boolean => { if (action === "egress") { if (pendingEgressHashesRef.current.has(hash)) { @@ -651,6 +692,7 @@ export function NodesPage() { cell: (info) => { const node = info.row.original; const disablePending = isDisableActionPending(node.node_hash); + const cleanupPending = isCleanupActionPending(node.node_hash); return (
event.stopPropagation()}> @@ -687,11 +729,21 @@ export function NodesPage() { variant="ghost" title={t("禁用节点(立即解除全部租约)")} onClick={() => void runDisableNode(node.node_hash)} - disabled={disablePending} + disabled={disablePending || cleanupPending} > )} +
); }, diff --git a/webui/src/features/nodes/api.ts b/webui/src/features/nodes/api.ts index a02b05d3..843a1c77 100644 --- a/webui/src/features/nodes/api.ts +++ b/webui/src/features/nodes/api.ts @@ -133,6 +133,11 @@ export type DisableNodeResult = { released_lease_count: number; }; +export type CleanupNodeResult = { + evicted_subscription_count: number; + released_lease_count: number; +}; + export async function disableNode(hash: string): Promise { return apiRequest(`${basePath}/${hash}/actions/disable`, { method: "POST", @@ -144,3 +149,9 @@ export async function enableNode(hash: string): Promise { method: "POST", }); } + +export async function cleanupNode(hash: string): Promise { + return apiRequest(`${basePath}/${hash}/actions/cleanup`, { + method: "POST", + }); +} diff --git a/webui/src/features/platforms/PlatformAccessPanel.tsx b/webui/src/features/platforms/PlatformAccessPanel.tsx index a28ee8a0..7d3d4c21 100644 --- a/webui/src/features/platforms/PlatformAccessPanel.tsx +++ b/webui/src/features/platforms/PlatformAccessPanel.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Check, Copy, Info } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "../../components/ui/Button"; import { Input } from "../../components/ui/Input"; import { useI18n } from "../../i18n"; @@ -15,17 +15,25 @@ function loadStoredProxyToken(): string { if (typeof window === "undefined") { return ""; } - return window.localStorage.getItem(PROXY_TOKEN_STORAGE_KEY) ?? ""; + try { + return window.localStorage.getItem(PROXY_TOKEN_STORAGE_KEY) ?? ""; + } catch { + return ""; + } } function persistProxyToken(value: string): void { if (typeof window === "undefined") { return; } - if (value) { - window.localStorage.setItem(PROXY_TOKEN_STORAGE_KEY, value); - } else { - window.localStorage.removeItem(PROXY_TOKEN_STORAGE_KEY); + try { + if (value) { + window.localStorage.setItem(PROXY_TOKEN_STORAGE_KEY, value); + } else { + window.localStorage.removeItem(PROXY_TOKEN_STORAGE_KEY); + } + } catch { + // Browser storage can be unavailable in private or restricted contexts. } } @@ -179,6 +187,19 @@ export function PlatformAccessPanel({ platformName }: PlatformAccessPanelProps) const [token, setToken] = useState(loadStoredProxyToken); const [target, setTarget] = useState("https://api.ipify.org"); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + const onStorage = (event: StorageEvent) => { + if (event.key === PROXY_TOKEN_STORAGE_KEY) { + setToken(event.newValue ?? ""); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + const envQuery = useQuery({ queryKey: ["system-env-config"], queryFn: getEnvConfig, diff --git a/webui/src/features/subscriptions/SubscriptionPage.tsx b/webui/src/features/subscriptions/SubscriptionPage.tsx index b74569de..e013e2b8 100644 --- a/webui/src/features/subscriptions/SubscriptionPage.tsx +++ b/webui/src/features/subscriptions/SubscriptionPage.tsx @@ -25,11 +25,12 @@ import { cleanupSubscriptionCircuitOpenNodes, createSubscription, deleteSubscription, + getHistoryTrafficTotal, listSubscriptions, refreshSubscription, updateSubscription, } from "./api"; -import type { Subscription } from "./types"; +import type { Subscription, SubscriptionSummary } from "./types"; type EnabledFilter = "all" | "enabled" | "disabled"; type SubscriptionSourceType = "remote" | "local"; @@ -72,6 +73,15 @@ const subscriptionEditSchema = subscriptionCreateSchema; type SubscriptionCreateForm = z.infer; type SubscriptionEditForm = z.infer; const EMPTY_SUBSCRIPTIONS: Subscription[] = []; +const EMPTY_SUBSCRIPTION_SUMMARY: SubscriptionSummary = { + enabled_count: 0, + disabled_count: 0, + usage_used_bytes: 0, + usage_total_bytes: 0, + usage_remaining_bytes: 0, + healthy_node_count: 0, + node_count: 0, +}; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; const LOCAL_SOURCE_UPDATE_INTERVAL = "12h"; const SUBSCRIPTION_DISABLE_HINT = "禁用订阅后,相关节点不会参与平台路由、健康统计或自动探测。"; @@ -141,8 +151,19 @@ function formatSubscriptionExpire(subscription: Subscription, formatter: (input: return formatter(new Date(expireUnix * 1000).toISOString()); } +function getYesterdayTrafficWindow(): { from: string; to: string } { + const to = new Date(); + to.setHours(0, 0, 0, 0); + const from = new Date(to); + from.setDate(from.getDate() - 1); + return { + from: from.toISOString(), + to: to.toISOString(), + }; +} + export function SubscriptionPage() { - const { t } = useI18n(); + const { locale, t } = useI18n(); const [enabledFilter, setEnabledFilter] = useState("all"); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); @@ -180,9 +201,22 @@ export function SubscriptionPage() { refetchInterval: 30_000, placeholderData: (prev) => prev, }); + const yesterdayTrafficWindow = useMemo(() => getYesterdayTrafficWindow(), []); + const yesterdayTrafficQuery = useQuery({ + queryKey: ["metrics", "history", "traffic", "yesterday", yesterdayTrafficWindow.from, yesterdayTrafficWindow.to], + queryFn: () => getHistoryTrafficTotal(yesterdayTrafficWindow), + refetchInterval: 300_000, + placeholderData: (prev) => prev, + }); const subscriptions = subscriptionsQuery.data?.items ?? EMPTY_SUBSCRIPTIONS; const totalSubscriptions = subscriptionsQuery.data?.total ?? 0; + const subscriptionSummary = subscriptionsQuery.data?.summary ?? EMPTY_SUBSCRIPTION_SUMMARY; + const countFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]); + const formatCount = (value: number) => countFormatter.format(Math.round(value)); + const usageTotalText = subscriptionSummary.usage_total_bytes > 0 ? formatBytes(subscriptionSummary.usage_total_bytes) : "-"; + const usageRemainingText = subscriptionSummary.usage_total_bytes > 0 ? formatBytes(subscriptionSummary.usage_remaining_bytes) : "-"; + const yesterdayTrafficText = yesterdayTrafficQuery.isError ? "-" : formatBytes(yesterdayTrafficQuery.data ?? 0); const totalPages = Math.max(1, Math.ceil(totalSubscriptions / pageSize)); const currentPage = Math.min(page, totalPages - 1); @@ -587,7 +621,16 @@ export function SubscriptionPage() {

{t("订阅列表")}

-

{t("共 {{count}} 个订阅", { count: totalSubscriptions })}

+

+ {t("共 {{count}} 个订阅", { count: totalSubscriptions })} + {subscriptionsQuery.data ? ( + <> + {t("订阅:")} {t("启用")} {formatCount(subscriptionSummary.enabled_count)} / {t("禁用")} {formatCount(subscriptionSummary.disabled_count)} + {t("节点:")} {`${formatCount(subscriptionSummary.healthy_node_count)} / ${formatCount(subscriptionSummary.node_count)}`} + {t("流量:")} {`${formatBytes(subscriptionSummary.usage_used_bytes)} / ${usageTotalText}`} · {t("剩余")} {usageRemainingText} · {t("昨日")} {yesterdayTrafficText} + + ) : null} +

From 9bcbe645d42b6b987b2860f5fc1399133d5abbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sun, 5 Jul 2026 17:04:05 +0800 Subject: [PATCH 23/25] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=A7=9F=E7=BA=A6=E7=BB=91=E5=AE=9A=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/api/handler_lease.go: 支持租约列表按参考延迟排序 - internal/api/contract_test.go: 补充租约延迟排序合同测试 - webui/src/features/platforms/PlatformLeasesPanel.tsx: 将绑定节点选择改为表格并展示已绑定租约名称 - webui/src/styles/theme.css: 补充绑定弹窗表格、选择按钮和暗色主题样式 - webui/src/i18n/translations.ts: 补充租约绑定交互文案翻译 --- internal/api/contract_test.go | 97 +++++++ internal/api/handler_lease.go | 31 +- .../platforms/PlatformLeasesPanel.tsx | 274 ++++++++++++++---- webui/src/i18n/translations.ts | 23 ++ webui/src/styles/theme.css | 159 +++++++++- 5 files changed, 509 insertions(+), 75 deletions(-) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 2c09b7b8..2b502d38 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -518,6 +518,103 @@ func TestAPIContract_GetLease_IncludesNodeTag(t *testing.T) { } } +func TestAPIContract_ListLeases_SortByReferenceLatency(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + + platformID := mustCreatePlatform(t, srv, "lease-latency-sort") + sub := subscription.NewSubscription("lease-latency-sub", "Latency", "https://example.com/latency", true, false) + cp.SubMgr.Register(sub) + + addNode := func(raw []byte, tag, egressIP string, latencyMs int) node.Hash { + t.Helper() + hash := node.HashFromRawOptions(raw) + sub.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{tag}}) + cp.Pool.AddNodeFromSub(hash, raw, sub.ID) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing", hash.Hex()) + } + entry.SetEgressIP(netip.MustParseAddr(egressIP)) + if latencyMs >= 0 { + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: time.Duration(latencyMs) * time.Millisecond, + LastUpdated: time.Now(), + }) + } + return hash + } + + fast := addNode([]byte(`{"type":"ss","server":"198.51.100.61","port":443}`), "fast", "203.0.113.61", 20) + slow := addNode([]byte(`{"type":"ss","server":"198.51.100.62","port":443}`), "slow", "203.0.113.62", 80) + unknown := addNode([]byte(`{"type":"ss","server":"198.51.100.63","port":443}`), "unknown", "203.0.113.63", -1) + + now := time.Now().UnixNano() + cp.Router.RestoreLeases([]model.Lease{ + { + PlatformID: platformID, + Account: "slow", + NodeHash: slow.Hex(), + EgressIP: "203.0.113.62", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }, + { + PlatformID: platformID, + Account: "unknown", + NodeHash: unknown.Hex(), + EgressIP: "203.0.113.63", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }, + { + PlatformID: platformID, + Account: "fast", + NodeHash: fast.Hex(), + EgressIP: "203.0.113.61", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }, + }) + + assertOrder := func(sortOrder string, want []string) { + t.Helper() + rec := doJSONRequest( + t, + srv, + http.MethodGet, + "/api/v1/platforms/"+platformID+"/leases?sort_by=reference_latency_ms&sort_order="+sortOrder, + nil, + true, + ) + if rec.Code != http.StatusOK { + t.Fatalf("list leases status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + items, ok := body["items"].([]any) + if !ok { + t.Fatalf("items type: got %T", body["items"]) + } + if len(items) != len(want) { + t.Fatalf("items len: got %d, want %d", len(items), len(want)) + } + for i, item := range items { + row, ok := item.(map[string]any) + if !ok { + t.Fatalf("item type: got %T", item) + } + if row["account"] != want[i] { + t.Fatalf("account[%d]: got %v, want %q (body=%s)", i, row["account"], want[i], rec.Body.String()) + } + } + } + + assertOrder("asc", []string{"fast", "slow", "unknown"}) + assertOrder("desc", []string{"slow", "fast", "unknown"}) +} + func TestAPIContract_ListLeases_AccountFuzzySearch(t *testing.T) { srv, cp, _ := newControlPlaneTestServer(t) diff --git a/internal/api/handler_lease.go b/internal/api/handler_lease.go index 161e4ca2..7a84894e 100644 --- a/internal/api/handler_lease.go +++ b/internal/api/handler_lease.go @@ -34,6 +34,25 @@ func leaseSortKey(sortBy string, l service.LeaseResponse) string { } } +func sortLeaseResponsesByLatency(leases []service.LeaseResponse, sorting Sorting) { + slices.SortStableFunc(leases, func(a, b service.LeaseResponse) int { + if a.ReferenceLatencyMs == nil && b.ReferenceLatencyMs == nil { + return strings.Compare(a.Account, b.Account) + } + if a.ReferenceLatencyMs == nil { + return 1 + } + if b.ReferenceLatencyMs == nil { + return -1 + } + order := cmp.Compare(*a.ReferenceLatencyMs, *b.ReferenceLatencyMs) + if order != 0 { + return applySortOrder(order, sorting.SortOrder) + } + return strings.Compare(a.Account, b.Account) + }) +} + func compareIPLoadEntries(sortBy string, a, b service.IPLoadEntry) int { switch sortBy { case "egress_ip": @@ -98,13 +117,17 @@ func HandleListLeases(cp *service.ControlPlaneService) http.HandlerFunc { leases = filtered } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "node_tag", "egress_ip", "created_at", "expiry", "last_accessed"}, "expiry", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "node_tag", "egress_ip", "reference_latency_ms", "created_at", "expiry", "last_accessed"}, "expiry", "asc") if !ok { return } - SortSlice(leases, sorting, func(l service.LeaseResponse) string { - return leaseSortKey(sorting.SortBy, l) - }) + if sorting.SortBy == "reference_latency_ms" { + sortLeaseResponsesByLatency(leases, sorting) + } else { + SortSlice(leases, sorting, func(l service.LeaseResponse) string { + return leaseSortKey(sorting.SortBy, l) + }) + } pg, ok := parsePaginationOrWriteInvalid(w, r) if !ok { diff --git a/webui/src/features/platforms/PlatformLeasesPanel.tsx b/webui/src/features/platforms/PlatformLeasesPanel.tsx index 90d66bf7..5c07c400 100644 --- a/webui/src/features/platforms/PlatformLeasesPanel.tsx +++ b/webui/src/features/platforms/PlatformLeasesPanel.tsx @@ -1,12 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createColumnHelper } from "@tanstack/react-table"; -import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Link2Off, Plus, Search, Sparkles } from "lucide-react"; +import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Copy, Link2Off, Plus, Search, Sparkles, X } from "lucide-react"; import { type ReactNode, useState } from "react"; import { Button } from "../../components/ui/Button"; +import { Card } from "../../components/ui/Card"; import { DataTable } from "../../components/ui/DataTable"; import { Input } from "../../components/ui/Input"; import { OffsetPagination } from "../../components/ui/OffsetPagination"; -import { Select } from "../../components/ui/Select"; import { useI18n } from "../../i18n"; import { formatApiErrorMessage } from "../../lib/error-message"; import { formatRelativeTime } from "../../lib/time"; @@ -20,7 +20,7 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100] as const; const columnHelper = createColumnHelper(); -type SortField = "account" | "node_tag" | "egress_ip" | "created_at" | "expiry" | "last_accessed"; +type SortField = "account" | "node_tag" | "egress_ip" | "reference_latency_ms" | "created_at" | "expiry" | "last_accessed"; type SortOrder = "asc" | "desc"; type Props = { @@ -69,6 +69,19 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { enabled: bindOpen, }); + const bindLeasesQuery = useQuery({ + queryKey: ["platform-leases", platform.id, "bind-map"], + queryFn: () => + listPlatformLeases(platform.id, { + limit: 100000, + offset: 0, + sort_by: "account", + sort_order: "asc", + }), + enabled: bindOpen, + placeholderData: (prev) => prev, + }); + const nodeDetailQuery = useQuery({ queryKey: ["node", detailNodeHash], queryFn: () => getNode(detailNodeHash), @@ -85,6 +98,47 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { return aLat - bLat; }); + const leaseAccountsByNode = new Map(); + for (const lease of bindLeasesQuery.data?.items ?? []) { + const accounts = leaseAccountsByNode.get(lease.node_hash) ?? []; + accounts.push(lease.account); + leaseAccountsByNode.set(lease.node_hash, accounts); + } + + const openBindModal = (lease?: LeaseResponse) => { + setBindAccount(lease?.account ?? ""); + setSelectedNodeHash(lease?.node_hash ?? ""); + setBindOpen(true); + }; + + const closeBindModal = () => { + setBindOpen(false); + setBindAccount(""); + setSelectedNodeHash(""); + }; + + const copyLeaseName = async () => { + const account = bindAccount.trim(); + if (!account) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(account); + } else { + const area = document.createElement("textarea"); + area.value = account; + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + document.execCommand("copy"); + document.body.removeChild(area); + } + showToast("success", t("租约名称已复制")); + } catch { + showToast("error", t("复制失败")); + } + }; + const invalidateLeases = async () => { await queryClient.invalidateQueries({ queryKey: ["platform-leases", platform.id] }); await queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }); @@ -113,9 +167,7 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { mutationFn: () => bindPlatformLease(platform.id, bindAccount.trim(), selectedNodeHash), onSuccess: async (lease) => { await invalidateLeases(); - setBindOpen(false); - setBindAccount(""); - setSelectedNodeHash(""); + closeBindModal(); showToast("success", t("租约 {{account}} 已绑定到 {{ip}}", { account: lease.account, ip: lease.egress_ip })); }, onError: (error) => { @@ -194,7 +246,19 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { const leaseColumns = [ columnHelper.accessor("account", { header: () => sortHeader(t("Account"), "account"), - cell: (info) => {info.getValue()}, + cell: (info) => ( + + ), }), columnHelper.accessor("node_tag", { header: () => sortHeader(t("节点"), "node_tag"), @@ -205,7 +269,7 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { }), columnHelper.display({ id: "reference_latency_ms", - header: t("参考延迟"), + header: () => sortHeader(t("参考延迟"), "reference_latency_ms"), cell: (info) => { const latencyMs = info.row.original.reference_latency_ms; if (typeof latencyMs !== "number") { @@ -264,61 +328,157 @@ export function PlatformLeasesPanel({ platform, showToast }: Props) { }} />
- {bindOpen ? ( -
-
- setBindAccount(e.target.value)} - required - /> -
-
- -
-
- - -
-
+
+ event.stopPropagation()}> +
+

{t("绑定租约")}

+ +
+ +
+
+ + setBindAccount(e.target.value)} + required + /> +
+ + {bindAccount.trim() ? ( + + ) : null} + +
+
+ {t("选择节点")} + {nodesQuery.isLoading ? t("加载节点中...") : t("共 {{count}} 个节点", { count: sortedNodes.length })} +
+ + {nodesQuery.isError ? ( +
+ + {formatApiErrorMessage(nodesQuery.error, t)} +
+ ) : null} + + {bindLeasesQuery.isError ? ( +
+ + {formatApiErrorMessage(bindLeasesQuery.error, t)} +
+ ) : null} + + {nodesQuery.isLoading ?

{t("加载节点中...")}

: null} + + {!nodesQuery.isLoading && !sortedNodes.length ? ( +
+ +

{t("没有匹配的节点")}

+
+ ) : null} + + {sortedNodes.length ? ( +
+ + + + + + + + + + + + {sortedNodes.map((nd) => { + const tag = nd.tags.map((tg) => tg.tag).join(", ") || nd.display_tag || nd.node_hash.slice(0, 8); + const latencyMs = nd.reference_latency_ms; + const accounts = leaseAccountsByNode.get(nd.node_hash) ?? []; + const hiddenAccountCount = Math.max(0, accounts.length - 2); + const accountText = accounts.slice(0, 2).join(", "); + const boundTitle = accounts.join(", "); + const isOccupied = accounts.length > 0 || nd.lease_count > 0; + const selected = selectedNodeHash === nd.node_hash; + return ( + + + + + + + + ); + })} + +
{t("节点")}{t("出口 IP")}{t("参考延迟")}{t("已绑定")}{t("操作")}
+ {tag} + + {nd.egress_ip || "-"} + + + {typeof latencyMs === "number" ? formatLatency(latencyMs) : "-"} + + + {accounts.length ? ( + <> + {t("已绑定:")} + + {accountText} + {hiddenAccountCount ? ` ${t("+{{count}}", { count: hiddenAccountCount })}` : ""} + + + ) : isOccupied && bindLeasesQuery.isLoading ? ( + {t("加载中...")} + ) : ( + "-" + )} + + +
+
+ ) : null} +
+ +
+ + +
+
+
+
) : null} {leasesQuery.isLoading ?

{t("正在加载租约数据...")}

: null} diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts index 2be3dab5..785fa776 100644 --- a/webui/src/i18n/translations.ts +++ b/webui/src/i18n/translations.ts @@ -303,6 +303,29 @@ const EXACT_ZH_TO_EN: Record = { "Enter a valid http/https target URL to generate the reverse proxy URL.", "复制": "Copy", "已复制": "Copied", + "复制失败": "Copy failed", + "租约": "Leases", + "绑定租约": "Bind Lease", + "绑定租约 {{account}}": "Bind lease {{account}}", + "租约名称": "Lease name", + "点击复制租约名称": "Click to copy lease name", + "租约名称已复制": "Lease name copied", + "选择节点": "Select node", + "选择": "Select", + "已选择": "Selected", + "已绑定": "Bound", + "已绑定:": "Bound:", + "+{{count}}": "+{{count}}", + "加载中...": "Loading...", + "加载节点中...": "Loading nodes...", + "共 {{count}} 个节点": "{{count}} nodes", + "绑定中...": "Binding...", + "确认绑定": "Bind", + "没有租约": "No leases", + "正在加载租约数据...": "Loading lease data...", + "租约 {{account}} 已绑定到 {{ip}}": "Lease {{account}} bound to {{ip}}", + "租约 {{account}} 已解绑": "Lease {{account}} unbound", + "确认解绑租约 {{account}}?": "Unbind lease {{account}}?", "HTTP": "HTTP", "随机选择节点": "Select node randomly", "按空账号处理": "Treat as empty account", diff --git a/webui/src/styles/theme.css b/webui/src/styles/theme.css index ca8e2a3e..fe392faa 100644 --- a/webui/src/styles/theme.css +++ b/webui/src/styles/theme.css @@ -2865,28 +2865,128 @@ a { .platform-leases-search .form-input { flex: 1; } -.platform-leases-bind-form { +.platform-lease-bind-modal { + width: min(760px, 92vw); +} +.platform-lease-bind-form { display: flex; + flex-direction: column; + gap: 12px; +} +.lease-copy-chip { + align-self: flex-start; + display: inline-flex; align-items: center; - gap: 8px; - padding: 10px 12px; + max-width: 100%; + gap: 6px; border: 1px solid rgba(37, 72, 120, 0.14); - border-radius: 10px; - background: rgba(255, 255, 255, 0.84); - flex-wrap: nowrap; + border-radius: 999px; + padding: 5px 10px; + background: rgba(20, 112, 255, 0.07); + color: var(--primary); + cursor: pointer; + font: inherit; } -.bind-field { +.lease-copy-chip span { min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); + font-size: 12px; } -.bind-field-account { - flex: 1; +.platform-lease-node-picker { + display: flex; + flex-direction: column; + gap: 8px; +} +.platform-lease-node-picker-head { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: var(--text-muted); } -.bind-field-node { - flex: 2; +.platform-lease-node-table-wrap { + max-height: min(42vh, 360px); + overflow: auto; + border-radius: 8px; + border: 1px solid rgba(37, 72, 120, 0.14); } -.bind-field .form-input, -.bind-field .form-select { +.platform-lease-node-table { width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} +.platform-lease-node-table th, +.platform-lease-node-table td { + padding: 9px 10px; + border-bottom: 1px solid rgba(37, 72, 120, 0.1); + vertical-align: middle; +} +.platform-lease-node-table th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(240, 247, 255, 0.96); + color: var(--text-muted); + font-weight: 700; + text-align: left; +} +.platform-lease-node-table th:nth-child(2), +.platform-lease-node-table td:nth-child(2) { + width: 122px; +} +.platform-lease-node-table th:nth-child(3), +.platform-lease-node-table td:nth-child(3) { + width: 92px; +} +.platform-lease-node-table th:nth-child(4), +.platform-lease-node-table td:nth-child(4) { + width: 190px; +} +.platform-lease-node-table th:nth-child(5), +.platform-lease-node-table td:nth-child(5) { + width: 78px; + text-align: right; +} +.platform-lease-node-table tbody tr { + background: rgba(255, 255, 255, 0.64); + transition: background-color 0.15s, opacity 0.15s; +} +.platform-lease-node-table tbody tr:hover, +.platform-lease-node-table tbody tr.selected { + background: rgba(20, 112, 255, 0.08); +} +.platform-lease-node-table tbody tr.occupied:not(.selected) { + opacity: 0.68; +} +.platform-lease-node-name, +.platform-lease-node-ip, +.platform-lease-node-bound { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.platform-lease-node-name { + font-weight: 600; +} +.platform-lease-node-latency { + font-weight: 700; +} +.platform-lease-bound-label { + color: var(--text-muted); + margin-right: 4px; +} +.platform-lease-bound-accounts { + color: var(--text); + font-family: var(--font-mono, monospace); +} +.platform-lease-node-action .btn { + min-width: 64px; + justify-content: center; } .bind-actions { display: flex; @@ -2900,6 +3000,24 @@ a { font-family: var(--font-mono, monospace); font-size: 12px; } +.lease-account-button { + max-width: 220px; + border: 0; + padding: 0; + background: transparent; + color: var(--primary); + cursor: pointer; + font: inherit; + font-family: var(--font-mono, monospace); + font-size: 12px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lease-account-button:hover { + text-decoration: underline; +} .lease-sort-header { display: inline-flex; align-items: center; @@ -3607,10 +3725,23 @@ a { background: rgba(255, 255, 255, 0.04); } -[data-theme="dark"] .platform-leases-bind-form { +[data-theme="dark"] .lease-copy-chip { + background: rgba(255, 255, 255, 0.04); +} + +[data-theme="dark"] .platform-lease-node-table th { + background: rgba(22, 34, 55, 0.96); +} + +[data-theme="dark"] .platform-lease-node-table tbody tr { background: rgba(255, 255, 255, 0.04); } +[data-theme="dark"] .platform-lease-node-table tbody tr:hover, +[data-theme="dark"] .platform-lease-node-table tbody tr.selected { + background: rgba(20, 112, 255, 0.12); +} + [data-theme="dark"] .logs-detail-block { background: rgba(255, 255, 255, 0.04); } From a86908606b3a1f67b4bc2a1fc62f0ecb5fd0c7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Sun, 5 Jul 2026 18:00:10 +0800 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=20outbound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 节点详情接口: 返回 outbound JSON 和可复制的 http/https/socks5 代理地址 - 节点详情前端: 展示 outbound JSON、代理地址并支持一键复制 - 测试覆盖: 增加详情字段、代理地址生成和列表不携带大 JSON 的校验 --- internal/api/handler_node_test.go | 55 +++++++ internal/service/control_plane_nodes.go | 2 +- internal/service/control_plane_nodes_test.go | 43 ++++++ internal/service/control_plane_platform.go | 138 +++++++++++++++--- webui/src/features/nodes/NodeDetailDrawer.tsx | 78 +++++++++- webui/src/features/nodes/types.ts | 7 + webui/src/i18n/translations.ts | 4 + webui/src/styles/theme.css | 59 ++++++++ 8 files changed, 365 insertions(+), 21 deletions(-) diff --git a/internal/api/handler_node_test.go b/internal/api/handler_node_test.go index e6beebf1..17f8da27 100644 --- a/internal/api/handler_node_test.go +++ b/internal/api/handler_node_test.go @@ -216,6 +216,61 @@ func TestHandleListNodes_IncludesReferenceLatencyMs(t *testing.T) { } } +func TestHandleGetNode_IncludesOutboundDetails(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + + sub := subscription.NewSubscription("11111111-1111-1111-1111-111111111111", "sub-a", "https://example.com/a", true, false) + cp.SubMgr.Register(sub) + + raw := `{"type":"http","tag":"proxy","server":"proxy.example.com","server_port":8080,"username":"user","password":"pass"}` + hash := node.HashFromRawOptions([]byte(raw)) + addNodeForNodeListTestWithTag(t, cp, sub, raw, "203.0.113.10", "proxy") + + rec := doJSONRequest(t, srv, http.MethodGet, "/api/v1/nodes/"+hash.Hex(), nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("get node status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + outbound, ok := body["outbound"].(map[string]any) + if !ok { + t.Fatalf("outbound type: got %T", body["outbound"]) + } + if outbound["type"] != "http" || outbound["server"] != "proxy.example.com" { + t.Fatalf("outbound mismatch: %#v", outbound) + } + proxyURLs, ok := body["proxy_urls"].([]any) + if !ok || len(proxyURLs) != 1 { + t.Fatalf("proxy_urls mismatch: got %T %#v", body["proxy_urls"], body["proxy_urls"]) + } + first, ok := proxyURLs[0].(map[string]any) + if !ok { + t.Fatalf("proxy_urls[0] type: got %T", proxyURLs[0]) + } + if first["type"] != "http" || first["url"] != "http://user:pass@proxy.example.com:8080#proxy" { + t.Fatalf("proxy url mismatch: %#v", first) + } + + rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/nodes?subscription_id="+sub.ID, nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("list nodes status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + listBody := decodeJSONMap(t, rec) + items, ok := listBody["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("items mismatch: got %T len=%d", listBody["items"], len(items)) + } + item, ok := items[0].(map[string]any) + if !ok { + t.Fatalf("item type: got %T", items[0]) + } + if _, ok := item["outbound"]; ok { + t.Fatalf("list item should not include outbound: %#v", item["outbound"]) + } + if _, ok := item["proxy_urls"]; ok { + t.Fatalf("list item should not include proxy_urls: %#v", item["proxy_urls"]) + } +} + func TestHandleListNodes_SortsByReferenceLatencyMs(t *testing.T) { srv, cp, runtimeCfg := newControlPlaneTestServer(t) diff --git a/internal/service/control_plane_nodes.go b/internal/service/control_plane_nodes.go index ad21a4d8..69cec88e 100644 --- a/internal/service/control_plane_nodes.go +++ b/internal/service/control_plane_nodes.go @@ -242,7 +242,7 @@ func (s *ControlPlaneService) GetNode(hashStr string) (*NodeSummary, error) { if !ok { return nil, notFound("node not found") } - ns := s.nodeEntryToSummary(h, entry) + ns := s.nodeEntryToDetailSummary(h, entry) return &ns, nil } diff --git a/internal/service/control_plane_nodes_test.go b/internal/service/control_plane_nodes_test.go index fb38a66b..e75c8bb4 100644 --- a/internal/service/control_plane_nodes_test.go +++ b/internal/service/control_plane_nodes_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "net/netip" "sync/atomic" "testing" @@ -68,6 +69,48 @@ func addRoutableNodeForSubscriptionWithTag( return hash } +func TestProxyURLsFromOutbound(t *testing.T) { + tests := []struct { + name string + raw string + want []NodeProxyURL + }{ + { + name: "http", + raw: `{"type":"http","tag":"plain","server":"proxy.example.com","server_port":8080,"username":"user","password":"pass"}`, + want: []NodeProxyURL{{Type: "http", URL: "http://user:pass@proxy.example.com:8080#plain"}}, + }, + { + name: "https", + raw: `{"type":"http","tag":"tls","server":"proxy.example.com","server_port":8443,"tls":{"enabled":true,"server_name":"sni.example.com","insecure":true}}`, + want: []NodeProxyURL{{Type: "https", URL: "https://proxy.example.com:8443?insecure=true&sni=sni.example.com#tls"}}, + }, + { + name: "socks5", + raw: `{"type":"socks","tag":"sock","server":"127.0.0.1","server_port":1080,"version":"5"}`, + want: []NodeProxyURL{{Type: "socks5", URL: "socks5://127.0.0.1:1080#sock"}}, + }, + { + name: "unsupported", + raw: `{"type":"shadowsocks","server":"1.1.1.1","server_port":443}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := proxyURLsFromOutbound(json.RawMessage(tt.raw)) + if len(got) != len(tt.want) { + t.Fatalf("len = %d, want %d: %#v", len(got), len(tt.want), got) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("proxy url[%d] = %#v, want %#v", i, got[i], tt.want[i]) + } + } + }) + } +} + func TestListNodes_PlatformAndSubscriptionFiltersReturnIntersection(t *testing.T) { subMgr := topology.NewSubscriptionManager() pool := newNodeListTestPool(subMgr) diff --git a/internal/service/control_plane_platform.go b/internal/service/control_plane_platform.go index 141dbfa5..89c99ca5 100644 --- a/internal/service/control_plane_platform.go +++ b/internal/service/control_plane_platform.go @@ -1,11 +1,15 @@ package service import ( + "bytes" "encoding/json" "errors" "fmt" + "net" "net/netip" + "net/url" "regexp" + "strconv" "strings" "time" @@ -626,24 +630,31 @@ type PlatformSpecFilter struct { // NodeSummary is the API response for a node. type NodeSummary struct { - NodeHash string `json:"node_hash"` - CreatedAt string `json:"created_at"` - Enabled bool `json:"enabled"` - ManuallyDisabled bool `json:"manually_disabled"` - DisplayTag string `json:"display_tag,omitempty"` - HasOutbound bool `json:"has_outbound"` - LastError string `json:"last_error,omitempty"` - CircuitOpenSince *string `json:"circuit_open_since"` - FailureCount int `json:"failure_count"` - EgressIP string `json:"egress_ip,omitempty"` - Region string `json:"region,omitempty"` - LastEgressUpdate string `json:"last_egress_update,omitempty"` - LastLatencyProbeAttempt string `json:"last_latency_probe_attempt,omitempty"` - LastAuthorityLatencyProbeAttempt string `json:"last_authority_latency_probe_attempt,omitempty"` - ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` - LastEgressUpdateAttempt string `json:"last_egress_update_attempt,omitempty"` - LeaseCount int64 `json:"lease_count"` - Tags []NodeTag `json:"tags"` + NodeHash string `json:"node_hash"` + CreatedAt string `json:"created_at"` + Enabled bool `json:"enabled"` + ManuallyDisabled bool `json:"manually_disabled"` + DisplayTag string `json:"display_tag,omitempty"` + HasOutbound bool `json:"has_outbound"` + LastError string `json:"last_error,omitempty"` + CircuitOpenSince *string `json:"circuit_open_since"` + FailureCount int `json:"failure_count"` + EgressIP string `json:"egress_ip,omitempty"` + Region string `json:"region,omitempty"` + LastEgressUpdate string `json:"last_egress_update,omitempty"` + LastLatencyProbeAttempt string `json:"last_latency_probe_attempt,omitempty"` + LastAuthorityLatencyProbeAttempt string `json:"last_authority_latency_probe_attempt,omitempty"` + ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` + LastEgressUpdateAttempt string `json:"last_egress_update_attempt,omitempty"` + LeaseCount int64 `json:"lease_count"` + Tags []NodeTag `json:"tags"` + Outbound json.RawMessage `json:"outbound,omitempty"` + ProxyURLs []NodeProxyURL `json:"proxy_urls,omitempty"` +} + +type NodeProxyURL struct { + Type string `json:"type"` + URL string `json:"url"` } // IsHealthyAndEnabled follows the node-summary health rule used by API/UI @@ -736,6 +747,97 @@ func (s *ControlPlaneService) nodeEntryToSummary(h node.Hash, entry *node.NodeEn return ns } +func (s *ControlPlaneService) nodeEntryToDetailSummary(h node.Hash, entry *node.NodeEntry) NodeSummary { + ns := s.nodeEntryToSummary(h, entry) + raw := bytes.TrimSpace(entry.RawOptions) + if len(raw) == 0 || !json.Valid(raw) { + return ns + } + ns.Outbound = append(json.RawMessage(nil), raw...) + ns.ProxyURLs = proxyURLsFromOutbound(raw) + return ns +} + +type simpleProxyOutbound struct { + Type string `json:"type"` + Tag string `json:"tag"` + Server string `json:"server"` + ServerPort uint16 `json:"server_port"` + Username string `json:"username"` + Password string `json:"password"` + Version string `json:"version"` + TLS *simpleProxyTLS `json:"tls"` +} + +type simpleProxyTLS struct { + Enabled bool `json:"enabled"` + ServerName string `json:"server_name"` + Insecure bool `json:"insecure"` +} + +func proxyURLsFromOutbound(raw json.RawMessage) []NodeProxyURL { + var outbound simpleProxyOutbound + if err := json.Unmarshal(raw, &outbound); err != nil { + return nil + } + server := strings.TrimSpace(outbound.Server) + if server == "" || outbound.ServerPort == 0 { + return nil + } + + switch strings.ToLower(strings.TrimSpace(outbound.Type)) { + case "http": + if outbound.TLS != nil && outbound.TLS.Enabled { + return []NodeProxyURL{{Type: "https", URL: buildProxyURL("https", server, outbound)}} + } + return []NodeProxyURL{{Type: "http", URL: buildProxyURL("http", server, outbound)}} + case "socks", "socks5": + if !isSocks5Version(outbound.Version) { + return nil + } + return []NodeProxyURL{{Type: "socks5", URL: buildProxyURL("socks5", server, outbound)}} + default: + return nil + } +} + +func isSocks5Version(version string) bool { + switch strings.ToLower(strings.TrimSpace(version)) { + case "", "5", "5h", "socks5", "socks5h": + return true + default: + return false + } +} + +func buildProxyURL(scheme string, server string, outbound simpleProxyOutbound) string { + u := url.URL{ + Scheme: scheme, + Host: net.JoinHostPort(server, strconv.Itoa(int(outbound.ServerPort))), + } + if outbound.Username != "" || outbound.Password != "" { + if outbound.Password == "" { + u.User = url.User(outbound.Username) + } else { + u.User = url.UserPassword(outbound.Username, outbound.Password) + } + } + if tag := strings.TrimSpace(outbound.Tag); tag != "" { + u.Fragment = tag + } + if scheme == "https" && outbound.TLS != nil { + query := url.Values{} + if serverName := strings.TrimSpace(outbound.TLS.ServerName); serverName != "" { + query.Set("sni", serverName) + } + if outbound.TLS.Insecure { + query.Set("insecure", "true") + } + u.RawQuery = query.Encode() + } + return u.String() +} + // PreviewFilter returns nodes matching the given filter spec. func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSummary, error) { hasPlatformID := req.PlatformID != nil && *req.PlatformID != "" diff --git a/webui/src/features/nodes/NodeDetailDrawer.tsx b/webui/src/features/nodes/NodeDetailDrawer.tsx index 6b336155..9cd4284b 100644 --- a/webui/src/features/nodes/NodeDetailDrawer.tsx +++ b/webui/src/features/nodes/NodeDetailDrawer.tsx @@ -1,5 +1,5 @@ -import { X } from "lucide-react"; -import { useEffect } from "react"; +import { Check, Copy, X } from "lucide-react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; @@ -25,6 +25,47 @@ type Props = { isLatencyProbePending: (hash: string) => boolean; }; +async function copyText(value: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const area = document.createElement("textarea"); + area.value = value; + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + try { + area.select(); + document.execCommand("copy"); + } finally { + document.body.removeChild(area); + } +} + +function CopyButton({ value }: { value: string }) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await copyText(value); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }; + + return ( + + ); +} + export function NodeDetailDrawer({ node, onClose, @@ -49,6 +90,8 @@ export function NodeDetailDrawer({ const title = firstTag(node); const egressPending = isEgressProbePending(node.node_hash); const latencyPending = isLatencyProbePending(node.node_hash); + const outboundJSON = node.outbound == null ? "" : JSON.stringify(node.outbound, null, 2) ?? ""; + const proxyUrls = node.proxy_urls ?? []; return createPortal(
+ {outboundJSON ? ( +
+
+
+

{t("Outbound JSON")}

+

{t("节点原始 outbound 配置。")}

+
+ +
+
{outboundJSON}
+
+ ) : null} + + {proxyUrls.length ? ( +
+
+

{t("可复制代理地址")}

+

{t("已按后端可安全拼接的协议生成。")}

+
+
+ {proxyUrls.map((item) => ( +
+ {item.type.toUpperCase()} + {item.url} + +
+ ))} +
+
+ ) : null} +

{t("运维操作")}

diff --git a/webui/src/features/nodes/types.ts b/webui/src/features/nodes/types.ts index 9cce16e0..c68fbc7a 100644 --- a/webui/src/features/nodes/types.ts +++ b/webui/src/features/nodes/types.ts @@ -4,6 +4,11 @@ export type NodeTag = { tag: string; }; +export type NodeProxyUrl = { + type: "http" | "https" | "socks5"; + url: string; +}; + export type NodeSummary = { node_hash: string; created_at: string; @@ -23,6 +28,8 @@ export type NodeSummary = { last_egress_update_attempt?: string; lease_count: number; tags: NodeTag[]; + outbound?: unknown; + proxy_urls?: NodeProxyUrl[]; }; export type PageResponse = { diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts index 785fa776..c725cef2 100644 --- a/webui/src/i18n/translations.ts +++ b/webui/src/i18n/translations.ts @@ -304,6 +304,10 @@ const EXACT_ZH_TO_EN: Record = { "复制": "Copy", "已复制": "Copied", "复制失败": "Copy failed", + "Outbound JSON": "Outbound JSON", + "节点原始 outbound 配置。": "Raw outbound configuration for this node.", + "可复制代理地址": "Copyable proxy URLs", + "已按后端可安全拼接的协议生成。": "Generated for protocols the backend can safely compose.", "租约": "Leases", "绑定租约": "Bind Lease", "绑定租约 {{account}}": "Bind lease {{account}}", diff --git a/webui/src/styles/theme.css b/webui/src/styles/theme.css index fe392faa..0351abe1 100644 --- a/webui/src/styles/theme.css +++ b/webui/src/styles/theme.css @@ -2694,6 +2694,50 @@ a { color: var(--text-muted); } +.node-detail-section-head-row { + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.node-detail-copy-btn { + flex-shrink: 0; + gap: 4px; +} + +.node-outbound-json { + min-height: 0; + max-height: 280px; +} + +.node-proxy-url-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.node-proxy-url-item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(37, 72, 120, 0.04); + padding: 8px; +} + +.node-proxy-url-item code { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--text); +} + .platform-config-form { margin-top: 0; } @@ -3211,6 +3255,21 @@ a { width: 100%; } + .node-detail-section-head-row, + .node-proxy-url-item { + flex-direction: column; + align-items: stretch; + } + + .node-detail-copy-btn { + width: 100%; + } + + .node-proxy-url-item code { + white-space: pre-wrap; + word-break: break-all; + } + .platform-monitor-kpi-grid { grid-template-columns: 1fr; } From 968ddeeee8f567e095748d534b4686b4ee6dab59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 8 Jul 2026 22:42:05 +0800 Subject: [PATCH 25/25] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=AD=A3=E5=88=99=E6=8E=92=E9=99=A4=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 平台筛选: 新增 regex_exclude_filters 的模型、运行时匹配、API 与前端配置 - 持久化迁移: 新增 regex_exclude_filters_json 字段并修复历史迁移版本兼容 - 测试覆盖: 补充正则排除、预览过滤和迁移修复场景 - 文档说明: 更新 DESIGN 与 README 的筛选规则说明 --- DESIGN.md | 14 +- README.md | 10 ++ README.zh-CN.md | 10 ++ internal/model/models.go | 1 + internal/node/entry.go | 36 +++++ internal/node/entry_test.go | 24 ++++ internal/platform/model_codec.go | 31 ++++- internal/platform/model_codec_test.go | 27 ++++ internal/platform/platform.go | 8 +- internal/platform/platform_test.go | 15 ++ internal/service/control_plane_platform.go | 39 +++++- .../control_plane_platform_preview_test.go | 36 +++++ internal/service/control_plane_system.go | 1 + internal/service/control_plane_test.go | 12 +- internal/state/migrate.go | 60 ++++++-- ...atforms_add_regex_exclude_filters.down.sql | 1 + ...platforms_add_regex_exclude_filters.up.sql | 1 + internal/state/repo_state.go | 36 +++-- internal/state/repo_state_test.go | 130 ++++++++++++++++-- .../features/platforms/PlatformDetailPage.tsx | 25 ++++ webui/src/features/platforms/PlatformPage.tsx | 20 +++ webui/src/features/platforms/api.ts | 4 +- webui/src/features/platforms/formModel.ts | 5 + webui/src/features/platforms/types.ts | 3 + webui/src/i18n/translations.ts | 6 + 25 files changed, 509 insertions(+), 46 deletions(-) create mode 100644 internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql create mode 100644 internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql diff --git a/DESIGN.md b/DESIGN.md index 5304f06f..ad4d927b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -29,6 +29,7 @@ * Name:平台名,全局唯一。 * StickyTTL: time.Duration,该平台的粘性租约寿命。 * RegexFilters:一个正则表达式列表。按照节点的 Tag 的正则表达式过滤器。同时满足所有过滤器才符合条件。 +* RegexExcludeFilters:一个正则表达式列表。命中任意排除规则的节点不进入该平台。 * RegexFiltersCompiled:编译后的正则表达式列表。用于运行时匹配。随着 RegexFilters 更新。 * RegionFilters:一个地区列表。小写 ISO codes (e.g., "hk", "us")。节点的出口 IP 地区属于该列表才符合条件。空表示不做地区筛选。 * 反向代理 Account 为空时的行为:随机路由 / 固定 Header 提取 / 按 Account Header Rule 提取。 @@ -590,7 +591,7 @@ Resin 项目中所有的数据库都设计为单写,不会有多进程写入 ### SQLite 数据模型 #### state.db * system_config(config_json, version, updated_at_ns) -* platforms(id PK, name UNIQUE, sticky_ttl_ns, regex_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns) +* platforms(id PK, name UNIQUE, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns) * subscriptions(id PK, name, url, update_interval_ns, enabled, ephemeral, created_at_ns, updated_at_ns) * account_header_rules(url_prefix PK, headers_json, updated_at_ns) @@ -1153,6 +1154,7 @@ Body(partial patch 示例): "name": "Default", "sticky_ttl": "30m", "regex_filters": ["^sub1/.*", ".*hk.*"], + "regex_exclude_filters": [".*low-rate.*"], "region_filters": ["hk","us"], "routable_node_count": 123, "reverse_proxy_miss_action": "TREAT_AS_EMPTY|REJECT", @@ -1182,6 +1184,7 @@ Body: "name": "Platform-A", "sticky_ttl": "168h", "regex_filters": ["^sub1/.*"], + "regex_exclude_filters": [".*low-rate.*"], "region_filters": ["hk", "us"], "reverse_proxy_miss_action": "TREAT_AS_EMPTY", "reverse_proxy_empty_account_behavior": "ACCOUNT_HEADER_RULE", @@ -1194,7 +1197,7 @@ Body: 字段要求: * 必填字段:`name` -* 可选字段:`sticky_ttl`、`regex_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` +* 可选字段:`sticky_ttl`、`regex_filters`、`regex_exclude_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` * 不可传字段:`id`、`updated_at`、`routable_node_count` * 省略可选字段时,平台策略字段使用当前环境变量默认平台设置(`RESIN_DEFAULT_PLATFORM_*`)对应值;`passive_circuit_breaker_disabled` 默认 `false` @@ -1203,6 +1206,7 @@ Body: * `name`:trim 后需非空、全局唯一;不能为保留名 `Default` 或 `api`(大小写不敏感);且不能包含 `.:|/\@?#%~`、空格、tab、换行、回车。 * `sticky_ttl`:合法 Go duration。 * `regex_filters`:每项可被 regexp 编译。 +* `regex_exclude_filters`:每项可被 regexp 编译;节点命中任意一项即排除。 * `region_filters`:每项为 ISO 3166-1 alpha-2 小写代码。 * 枚举字段:`reverse_proxy_miss_action` 仅 `TREAT_AS_EMPTY|REJECT`;`reverse_proxy_empty_account_behavior` 仅 `RANDOM|FIXED_HEADER|ACCOUNT_HEADER_RULE`;`allocation_policy` 仅 `BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP`。 * `passive_circuit_breaker_disabled`:布尔值。设为 `true` 后,此 Platform 的用户代理请求失败不会增加节点熔断计数;主动探测不受影响。成功请求仍会清除节点连续失败计数并可恢复熔断节点。 @@ -1234,7 +1238,7 @@ Body(partial patch 示例): 字段要求: * 必填字段:无 -* 可改字段:`name`、`sticky_ttl`、`regex_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` +* 可改字段:`name`、`sticky_ttl`、`regex_filters`、`regex_exclude_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` * 不可改字段:`id`、`updated_at`、`routable_node_count` 关键校验:与“创建平台”一致。 @@ -1293,6 +1297,7 @@ Body(partial patch 示例): { "platform_spec": { "regex_filters": ["^subA/.*"], + "regex_exclude_filters": [".*low-rate.*"], "region_filters": ["hk", "us"] } } @@ -1301,12 +1306,13 @@ Body(partial patch 示例): 字段要求: * 必填字段:`platform_id` 与 `platform_spec` 二选一,且只能出现一个。 -* `platform_spec` 仅允许字段:`regex_filters`、`region_filters`。 +* `platform_spec` 仅允许字段:`regex_filters`、`regex_exclude_filters`、`region_filters`。 关键校验(最小集): * `platform_id`:必须存在。 * `platform_spec.regex_filters`:每项可被 regexp 编译。 +* `platform_spec.regex_exclude_filters`:每项可被 regexp 编译。 * `platform_spec.region_filters`:每项为 ISO 3166-1 alpha-2 小写代码。 错误码映射(最小集): diff --git a/README.md b/README.md index cd2f6c51..921bc2dc 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ us hk ``` +Regex filters are matched against `/`; all include regexes must match, and any exclude regex removes the node. For example, include a subscription but exclude low-rate nodes: + +``` +regex filters: +^ProviderA/.* + +regex exclude filters: +.*low-rate.* +``` + For forward proxy (HTTP / SOCKS5), include Platform in the auth info. Examples: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 6ec56b2c..d3e7b6b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -150,6 +150,16 @@ us hk ``` +正则规则会匹配 `<订阅名>/<节点名>`;包含正则需要全部命中,排除正则命中任意一条就会移除该节点。例如只使用某个订阅,但排除低倍率节点: + +``` +正则过滤规则: +^ProviderA/.* + +正则排除规则: +.*低倍率.* +``` + 对于正向代理(HTTP / SOCKS5),你可以在认证信息中填入希望使用的 Platform。下面分别给出一个 curl 例子: ```bash diff --git a/internal/model/models.go b/internal/model/models.go index 5830f2c2..e7571459 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -9,6 +9,7 @@ type Platform struct { Name string `json:"name"` StickyTTLNs int64 `json:"sticky_ttl_ns"` RegexFilters []string + RegexExcludeFilters []string RegionFilters []string ReverseProxyMissAction string `json:"reverse_proxy_miss_action"` ReverseProxyEmptyAccountBehavior string `json:"reverse_proxy_empty_account_behavior"` diff --git a/internal/node/entry.go b/internal/node/entry.go index befb4c39..d190a01f 100644 --- a/internal/node/entry.go +++ b/internal/node/entry.go @@ -157,6 +157,33 @@ func (e *NodeEntry) MatchRegexs(regexes []*regexp.Regexp, subLookup SubLookupFun return false } +// MatchAnyRegex reports whether any tag from any enabled subscription matches +// any regex. Empty regexes or a nil lookup never exclude anything. +func (e *NodeEntry) MatchAnyRegex(regexes []*regexp.Regexp, subLookup SubLookupFunc) bool { + if len(regexes) == 0 || subLookup == nil { + return false + } + + e.mu.RLock() + subs := make([]string, len(e.subscriptionIDs)) + copy(subs, e.subscriptionIDs) + e.mu.RUnlock() + + for _, subID := range subs { + name, enabled, tags, ok := subLookup(subID, e.Hash) + if !ok || !enabled { + continue + } + for _, tag := range tags { + candidate := name + "/" + tag + if matchesAny(candidate, regexes) { + return true + } + } + } + return false +} + // HasEnabledSubscription reports whether the node currently has at least one // enabled subscription reference, based on subLookup. // @@ -198,6 +225,15 @@ func matchesAll(s string, regexes []*regexp.Regexp) bool { return true } +func matchesAny(s string, regexes []*regexp.Regexp) bool { + for _, re := range regexes { + if re.MatchString(s) { + return true + } + } + return false +} + // --- Condition helpers for platform filtering --- // IsCircuitOpen returns true if the node is currently circuit-broken. diff --git a/internal/node/entry_test.go b/internal/node/entry_test.go index 20927c87..49fd09d3 100644 --- a/internal/node/entry_test.go +++ b/internal/node/entry_test.go @@ -168,6 +168,30 @@ func TestNodeEntry_MatchRegexs_MultiSub(t *testing.T) { } } +func TestNodeEntry_MatchAnyRegex_DisabledSubSkipped(t *testing.T) { + h := HashFromRawOptions([]byte(`{"type":"ss"}`)) + e := NewNodeEntry(h, nil, time.Now(), 0) + e.AddSubscriptionID("sub-disabled") + e.AddSubscriptionID("sub-enabled") + + lookup := func(subID string, hash Hash) (string, bool, []string, bool) { + switch subID { + case "sub-disabled": + return "Disabled", false, []string{"bad-node"}, true + case "sub-enabled": + return "Enabled", true, []string{"good-node"}, true + } + return "", false, nil, false + } + + if e.MatchAnyRegex([]*regexp.Regexp{regexp.MustCompile("bad")}, lookup) { + t.Fatal("disabled subscriptions should not trigger exclude regex") + } + if !e.MatchAnyRegex([]*regexp.Regexp{regexp.MustCompile("good")}, lookup) { + t.Fatal("enabled subscription tag should trigger exclude regex") + } +} + func TestNodeEntry_HasEnabledSubscription(t *testing.T) { h := HashFromRawOptions([]byte(`{"type":"ss"}`)) e := NewNodeEntry(h, nil, time.Now(), 0) diff --git a/internal/platform/model_codec.go b/internal/platform/model_codec.go index 8220e8c3..2fb985b6 100644 --- a/internal/platform/model_codec.go +++ b/internal/platform/model_codec.go @@ -30,23 +30,33 @@ func ValidateRegionFilters(regionFilters []string) error { return nil } -// CompileRegexFilters compiles regex filters in order. -func CompileRegexFilters(regexFilters []string) ([]*regexp.Regexp, error) { +func compileRegexFilters(field string, regexFilters []string) ([]*regexp.Regexp, error) { compiled := make([]*regexp.Regexp, 0, len(regexFilters)) for i, re := range regexFilters { c, err := regexp.Compile(re) if err != nil { - return nil, fmt.Errorf("regex_filters[%d]: invalid regex: %v", i, err) + return nil, fmt.Errorf("%s[%d]: invalid regex: %v", field, i, err) } compiled = append(compiled, c) } return compiled, nil } +// CompileRegexFilters compiles regex filters in order. +func CompileRegexFilters(regexFilters []string) ([]*regexp.Regexp, error) { + return compileRegexFilters("regex_filters", regexFilters) +} + +// CompileRegexExcludeFilters compiles regex exclusion filters in order. +func CompileRegexExcludeFilters(regexFilters []string) ([]*regexp.Regexp, error) { + return compileRegexFilters("regex_exclude_filters", regexFilters) +} + // NewConfiguredPlatform builds a runtime platform with non-filter settings applied. func NewConfiguredPlatform( id, name string, regexFilters []*regexp.Regexp, + regexExcludeFilters []*regexp.Regexp, regionFilters []string, stickyTTLNs int64, missAction string, @@ -61,6 +71,7 @@ func NewConfiguredPlatform( fixedHeaders = nil } plat := NewPlatform(id, name, regexFilters, regionFilters) + plat.RegexExcludeFilters = regexExcludeFilters plat.StickyTTLNs = stickyTTLNs plat.ReverseProxyMissAction = missAction plat.ReverseProxyEmptyAccountBehavior = emptyAccountBehavior @@ -80,12 +91,25 @@ func CompileModelRegexFilters(platformID string, regexFilters []string) ([]*rege return compiled, nil } +// CompileModelRegexExcludeFilters compiles regex exclusion filters from persisted model values. +func CompileModelRegexExcludeFilters(platformID string, regexFilters []string) ([]*regexp.Regexp, error) { + compiled, err := CompileRegexExcludeFilters(regexFilters) + if err != nil { + return nil, fmt.Errorf("decode platform %s regex_exclude_filters: %w", platformID, err) + } + return compiled, nil +} + // BuildFromModel builds a runtime platform from a persisted model.Platform. func BuildFromModel(mp model.Platform) (*Platform, error) { regexFilters, err := CompileModelRegexFilters(mp.ID, mp.RegexFilters) if err != nil { return nil, err } + regexExcludeFilters, err := CompileModelRegexExcludeFilters(mp.ID, mp.RegexExcludeFilters) + if err != nil { + return nil, err + } if err := ValidateRegionFilters(mp.RegionFilters); err != nil { return nil, err } @@ -117,6 +141,7 @@ func BuildFromModel(mp model.Platform) (*Platform, error) { mp.ID, mp.Name, regexFilters, + regexExcludeFilters, append([]string(nil), mp.RegionFilters...), mp.StickyTTLNs, string(missAction), diff --git a/internal/platform/model_codec_test.go b/internal/platform/model_codec_test.go index 3a4cec1b..8ff602a1 100644 --- a/internal/platform/model_codec_test.go +++ b/internal/platform/model_codec_test.go @@ -14,6 +14,7 @@ func TestBuildFromModel_Success(t *testing.T) { Name: "Platform-1", StickyTTLNs: 3600, RegexFilters: []string{`^us-.*$`}, + RegexExcludeFilters: []string{`bad`}, RegionFilters: []string{"us", "jp"}, ReverseProxyMissAction: "REJECT", ReverseProxyEmptyAccountBehavior: "FIXED_HEADER", @@ -59,6 +60,9 @@ func TestBuildFromModel_Success(t *testing.T) { if len(plat.RegexFilters) != 1 || !plat.RegexFilters[0].MatchString("us-node") { t.Fatalf("regex filters not compiled as expected: %+v", plat.RegexFilters) } + if len(plat.RegexExcludeFilters) != 1 || !plat.RegexExcludeFilters[0].MatchString("bad-node") { + t.Fatalf("regex exclude filters not compiled as expected: %+v", plat.RegexExcludeFilters) + } if len(plat.RegionFilters) != 2 || plat.RegionFilters[0] != "us" || plat.RegionFilters[1] != "jp" { t.Fatalf("region filters mismatch: %+v", plat.RegionFilters) } @@ -77,6 +81,19 @@ func TestBuildFromModel_InvalidRegex(t *testing.T) { } } +func TestBuildFromModel_InvalidRegexExclude(t *testing.T) { + _, err := BuildFromModel(model.Platform{ + ID: "plat-1", + RegexExcludeFilters: []string{`(broken`}, + }) + if err == nil { + t.Fatal("expected regex exclude decode error") + } + if !strings.Contains(err.Error(), "regex_exclude_filters") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestBuildFromModel_InvalidRegionFilters(t *testing.T) { _, err := BuildFromModel(model.Platform{ ID: "plat-1", @@ -188,6 +205,16 @@ func TestCompileRegexFilters_Invalid(t *testing.T) { } } +func TestCompileRegexExcludeFilters_Invalid(t *testing.T) { + _, err := CompileRegexExcludeFilters([]string{"(broken"}) + if err == nil { + t.Fatal("expected compile error") + } + if !strings.Contains(err.Error(), "regex_exclude_filters[0]") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestValidateRegionFilters_Invalid(t *testing.T) { err := ValidateRegionFilters([]string{"US"}) if err == nil { diff --git a/internal/platform/platform.go b/internal/platform/platform.go index e80df487..0e8f7e7a 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -29,8 +29,9 @@ type Platform struct { Name string // Filter configuration. - RegexFilters []*regexp.Regexp - RegionFilters []string // lowercase ISO codes, supports negation "!xx" + RegexFilters []*regexp.Regexp + RegexExcludeFilters []*regexp.Regexp + RegionFilters []string // lowercase ISO codes, supports negation "!xx" // Other config fields. StickyTTLNs int64 @@ -133,6 +134,9 @@ func (p *Platform) evaluateNode( if !entry.MatchRegexs(p.RegexFilters, subLookup) { return false } + if entry.MatchAnyRegex(p.RegexExcludeFilters, subLookup) { + return false + } // 3. Egress IP must be known. egressIP := entry.GetEgressIP() diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index 49fe3368..5a5a81bc 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -137,6 +137,21 @@ func TestPlatform_EvaluateNode_RegexFilter(t *testing.T) { } } +func TestPlatform_EvaluateNode_RegexExcludeFilter(t *testing.T) { + p := NewPlatform("p1", "Test", []*regexp.Regexp{regexp.MustCompile("us")}, nil) + p.RegexExcludeFilters = []*regexp.Regexp{regexp.MustCompile("fast")} + h := makeHash(`{"type":"ss"}`) + entry := makeFullyRoutableEntry(h, "sub1") + + p.FullRebuild(func(fn func(node.Hash, *node.NodeEntry) bool) { + fn(h, entry) + }, alwaysLookup, usGeoLookup) + + if p.View().Size() != 0 { + t.Fatal("node matching exclude regex should not be routable") + } +} + func TestPlatform_EvaluateNode_RegionFilter(t *testing.T) { p := NewPlatform("p1", "Test", nil, []string{"us"}) h := makeHash(`{"type":"ss"}`) diff --git a/internal/service/control_plane_platform.go b/internal/service/control_plane_platform.go index 89c99ca5..805fa8ba 100644 --- a/internal/service/control_plane_platform.go +++ b/internal/service/control_plane_platform.go @@ -32,6 +32,7 @@ type PlatformResponse struct { Name string `json:"name"` StickyTTL string `json:"sticky_ttl"` RegexFilters []string `json:"regex_filters"` + RegexExcludeFilters []string `json:"regex_exclude_filters"` RegionFilters []string `json:"region_filters"` RoutableNodeCount int `json:"routable_node_count"` EgressIPCount int `json:"egress_ip_count"` @@ -53,6 +54,7 @@ func platformToResponse(p model.Platform) PlatformResponse { Name: p.Name, StickyTTL: time.Duration(p.StickyTTLNs).String(), RegexFilters: append([]string(nil), p.RegexFilters...), + RegexExcludeFilters: append([]string(nil), p.RegexExcludeFilters...), RegionFilters: append([]string(nil), p.RegionFilters...), RoutableNodeCount: 0, EgressIPCount: 0, @@ -111,6 +113,7 @@ type platformConfig struct { Name string StickyTTLNs int64 RegexFilters []string + RegexExcludeFilters []string RegionFilters []string ReverseProxyMissAction string ReverseProxyEmptyAccountBehavior string @@ -138,8 +141,9 @@ func (s *ControlPlaneService) defaultPlatformConfig(name string) platformConfig return platformConfig{ Name: name, StickyTTLNs: int64(s.EnvCfg.DefaultPlatformStickyTTL), - RegexFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegexFilters...), - RegionFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegionFilters...), + RegexFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegexFilters...), + RegexExcludeFilters: []string{}, + RegionFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegionFilters...), ReverseProxyMissAction: s.EnvCfg.DefaultPlatformReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: normalizePlatformEmptyAccountBehavior( s.EnvCfg.DefaultPlatformReverseProxyEmptyAccountBehavior, @@ -156,6 +160,7 @@ func platformConfigFromModel(mp model.Platform) platformConfig { Name: mp.Name, StickyTTLNs: mp.StickyTTLNs, RegexFilters: append([]string(nil), mp.RegexFilters...), + RegexExcludeFilters: append([]string(nil), mp.RegexExcludeFilters...), RegionFilters: append([]string(nil), mp.RegionFilters...), ReverseProxyMissAction: mp.ReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: normalizePlatformEmptyAccountBehavior(mp.ReverseProxyEmptyAccountBehavior), @@ -171,6 +176,7 @@ func (cfg platformConfig) toModel(id string, updatedAtNs int64) model.Platform { Name: cfg.Name, StickyTTLNs: cfg.StickyTTLNs, RegexFilters: append([]string(nil), cfg.RegexFilters...), + RegexExcludeFilters: append([]string(nil), cfg.RegexExcludeFilters...), RegionFilters: append([]string(nil), cfg.RegionFilters...), ReverseProxyMissAction: cfg.ReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: cfg.ReverseProxyEmptyAccountBehavior, @@ -186,10 +192,15 @@ func (cfg platformConfig) toRuntime(id string) (*platform.Platform, error) { if err != nil { return nil, err } + compiledRegexExcludeFilters, err := platform.CompileRegexExcludeFilters(cfg.RegexExcludeFilters) + if err != nil { + return nil, err + } return platform.NewConfiguredPlatform( id, cfg.Name, compiledRegexFilters, + compiledRegexExcludeFilters, cfg.RegionFilters, cfg.StickyTTLNs, cfg.ReverseProxyMissAction, @@ -369,6 +380,7 @@ type CreatePlatformRequest struct { Name *string `json:"name"` StickyTTL *string `json:"sticky_ttl"` RegexFilters []string `json:"regex_filters"` + RegexExcludeFilters []string `json:"regex_exclude_filters"` RegionFilters []string `json:"region_filters"` ReverseProxyMissAction *string `json:"reverse_proxy_miss_action"` ReverseProxyEmptyAccountBehavior *string `json:"reverse_proxy_empty_account_behavior"` @@ -408,6 +420,9 @@ func (s *ControlPlaneService) CreatePlatform(req CreatePlatformRequest) (*Platfo if req.RegexFilters != nil { cfg.RegexFilters = req.RegexFilters } + if req.RegexExcludeFilters != nil { + cfg.RegexExcludeFilters = req.RegexExcludeFilters + } if req.RegionFilters != nil { cfg.RegionFilters = req.RegionFilters } @@ -508,6 +523,11 @@ func (s *ControlPlaneService) UpdatePlatform(id string, patchJSON json.RawMessag } else if ok { cfg.RegexFilters = filters } + if filters, ok, err := patch.optionalStringSlice("regex_exclude_filters"); err != nil { + return nil, err + } else if ok { + cfg.RegexExcludeFilters = filters + } regionFiltersPatched := false if filters, ok, err := patch.optionalStringSlice("region_filters"); err != nil { @@ -624,8 +644,9 @@ type PreviewFilterRequest struct { } type PlatformSpecFilter struct { - RegexFilters []string `json:"regex_filters"` - RegionFilters []string `json:"region_filters"` + RegexFilters []string `json:"regex_filters"` + RegexExcludeFilters []string `json:"regex_exclude_filters"` + RegionFilters []string `json:"region_filters"` } // NodeSummary is the API response for a node. @@ -848,6 +869,7 @@ func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSum } var regexFilters []*regexp.Regexp + var regexExcludeFilters []*regexp.Regexp var regionFilters []string if hasPlatformID { @@ -856,13 +878,19 @@ func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSum return nil, notFound("platform not found") } regexFilters = plat.RegexFilters + regexExcludeFilters = plat.RegexExcludeFilters regionFilters = plat.RegionFilters } else { compiled, err := platform.CompileRegexFilters(req.PlatformSpec.RegexFilters) if err != nil { return nil, invalidArg(err.Error()) } + compiledExclude, err := platform.CompileRegexExcludeFilters(req.PlatformSpec.RegexExcludeFilters) + if err != nil { + return nil, invalidArg(err.Error()) + } regexFilters = compiled + regexExcludeFilters = compiledExclude regionFilters = req.PlatformSpec.RegionFilters if err := platform.ValidateRegionFilters(regionFilters); err != nil { return nil, invalidArg(err.Error()) @@ -878,6 +906,9 @@ func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSum if !entry.MatchRegexs(regexFilters, subLookup) { return true } + if entry.MatchAnyRegex(regexExcludeFilters, subLookup) { + return true + } if len(regionFilters) > 0 { region := entry.GetRegion(nil) if s.GeoIP != nil { diff --git a/internal/service/control_plane_platform_preview_test.go b/internal/service/control_plane_platform_preview_test.go index 4e01bd5d..f32fff3b 100644 --- a/internal/service/control_plane_platform_preview_test.go +++ b/internal/service/control_plane_platform_preview_test.go @@ -2,6 +2,7 @@ package service import ( "net/netip" + "strings" "testing" "time" @@ -161,3 +162,38 @@ func TestPreviewFilter_RegionNegation_UnknownRegionExcluded(t *testing.T) { } } } + +func TestPreviewFilter_RegexExclude(t *testing.T) { + fixture := buildPreviewFilterFixture(t) + + nodes, err := fixture.cp.PreviewFilter(PreviewFilterRequest{ + PlatformSpec: &PlatformSpecFilter{ + RegexFilters: []string{".*"}, + RegexExcludeFilters: []string{"hk"}, + }, + }) + if err != nil { + t.Fatalf("PreviewFilter: %v", err) + } + for _, node := range nodes { + if node.NodeHash == fixture.hkHash { + t.Fatalf("hk node %s should have been excluded", fixture.hkHash) + } + } + if len(nodes) != 2 { + t.Fatalf("nodes len = %d, want 2", len(nodes)) + } +} + +func TestPreviewFilter_InvalidRegexExclude(t *testing.T) { + fixture := buildPreviewFilterFixture(t) + + _, err := fixture.cp.PreviewFilter(PreviewFilterRequest{ + PlatformSpec: &PlatformSpecFilter{ + RegexExcludeFilters: []string{"(broken"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "regex_exclude_filters[0]") { + t.Fatalf("err = %v, want regex_exclude_filters[0]", err) + } +} diff --git a/internal/service/control_plane_system.go b/internal/service/control_plane_system.go index 38fa863c..25c4307a 100644 --- a/internal/service/control_plane_system.go +++ b/internal/service/control_plane_system.go @@ -93,6 +93,7 @@ var platformPatchAllowedFields = map[string]bool{ "name": true, "sticky_ttl": true, "regex_filters": true, + "regex_exclude_filters": true, "region_filters": true, "reverse_proxy_miss_action": true, "reverse_proxy_empty_account_behavior": true, diff --git a/internal/service/control_plane_test.go b/internal/service/control_plane_test.go index f583cd4c..9333416b 100644 --- a/internal/service/control_plane_test.go +++ b/internal/service/control_plane_test.go @@ -469,7 +469,8 @@ func TestCreatePlatform_BuildsRoutableViewBeforePublish(t *testing.T) { } name := "new-platform" - created, err := cp.CreatePlatform(CreatePlatformRequest{Name: &name}) + regexExcludeFilters := []string{"nope"} + created, err := cp.CreatePlatform(CreatePlatformRequest{Name: &name, RegexExcludeFilters: regexExcludeFilters}) if err != nil { t.Fatalf("CreatePlatform: %v", err) } @@ -484,9 +485,15 @@ func TestCreatePlatform_BuildsRoutableViewBeforePublish(t *testing.T) { if !plat.View().Contains(hash) { t.Fatalf("new platform view should contain seeded hash %s", hash.Hex()) } + if !reflect.DeepEqual(created.RegexExcludeFilters, regexExcludeFilters) { + t.Fatalf("created regex_exclude_filters = %v, want %v", created.RegexExcludeFilters, regexExcludeFilters) + } if created.PassiveCircuitBreakerDisabled { t.Fatal("new platform should default passive circuit breaker to not disabled") } + if len(plat.RegexExcludeFilters) != 1 || plat.RegexExcludeFilters[0].String() != "nope" { + t.Fatalf("runtime regex_exclude_filters = %v, want [nope]", plat.RegexExcludeFilters) + } if plat.PassiveCircuitBreakerDisabled { t.Fatal("runtime platform should default passive circuit breaker to not disabled") } @@ -884,6 +891,7 @@ func TestDeletePlatform_DoesNotDecodeCorruptPersistedFiltersJSON(t *testing.T) { platformRow.Name, nil, nil, + nil, platformRow.StickyTTLNs, platformRow.ReverseProxyMissAction, string(platform.ReverseProxyEmptyAccountBehaviorAccountHeaderRule), @@ -947,6 +955,7 @@ func TestResetPlatformToDefault_SupportsBuiltInDefaultPlatform(t *testing.T) { defaultRow.Name, nil, nil, + nil, defaultRow.StickyTTLNs, defaultRow.ReverseProxyMissAction, string(platform.ReverseProxyEmptyAccountBehaviorAccountHeaderRule), @@ -1089,6 +1098,7 @@ func TestResetPlatformToDefault_DoesNotDecodeCorruptPersistedFiltersJSON(t *test platformRow.Name, nil, nil, + nil, platformRow.StickyTTLNs, platformRow.ReverseProxyMissAction, string(platform.ReverseProxyEmptyAccountBehaviorAccountHeaderRule), diff --git a/internal/state/migrate.go b/internal/state/migrate.go index 67bd9003..bf67df59 100644 --- a/internal/state/migrate.go +++ b/internal/state/migrate.go @@ -26,6 +26,7 @@ const ( stateVersionAddIncrementalAliveNodes = 5 stateVersionAddPassiveCircuitBreakerDisabled = 6 stateVersionAddSubscriptionUsageInfo = 7 + stateVersionAddRegexExcludeFilters = 8 stateLegacyBaselineVersion = stateVersionAddFixedAccountHeader stateBaseSchemaMigration = stateMigrationsPath + "/000001_state_base.up.sql" @@ -90,7 +91,7 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { return err } if hasVersion { - return repairAmbiguousStateVersion6(db, driver) + return repairStateMigrationMetadata(db, driver) } hasPlatforms, err := hasTable(db, "platforms") @@ -121,14 +122,27 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { if err != nil { return err } + hasRegexExcludeFilters, err := hasTableColumn(db, "platforms", "regex_exclude_filters_json") + if err != nil { + return err + } switch { case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasPassiveCircuitBreakerDisabled && hasSubscriptionUsageInfo: if err := ensureSubscriptionUsageInfoColumns(db); err != nil { return err } + if hasRegexExcludeFilters { + return setLegacyMigrationVersion(db, driver, stateVersionAddRegexExcludeFilters) + } return setLegacyMigrationVersion(db, driver, stateVersionAddSubscriptionUsageInfo) case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasPassiveCircuitBreakerDisabled: + if hasRegexExcludeFilters { + if err := ensureSubscriptionUsageInfoColumns(db); err != nil { + return err + } + return setLegacyMigrationVersion(db, driver, stateVersionAddRegexExcludeFilters) + } return setLegacyMigrationVersion(db, driver, stateVersionAddPassiveCircuitBreakerDisabled) case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasSubscriptionUsageInfo: if err := ensurePassiveCircuitBreakerColumn(db); err != nil { @@ -137,6 +151,9 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { if err := ensureSubscriptionUsageInfoColumns(db); err != nil { return err } + if hasRegexExcludeFilters { + return setLegacyMigrationVersion(db, driver, stateVersionAddRegexExcludeFilters) + } return setLegacyMigrationVersion(db, driver, stateVersionAddSubscriptionUsageInfo) case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes: return setLegacyMigrationVersion(db, driver, stateVersionAddIncrementalAliveNodes) @@ -161,25 +178,44 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { } } -func repairAmbiguousStateVersion6(db *sql.DB, driver migratedb.Driver) error { +func repairStateMigrationMetadata(db *sql.DB, driver migratedb.Driver) error { version, dirty, err := driver.Version() if err != nil { return fmt.Errorf("read migration version: %w", err) } - if dirty || version != stateVersionAddPassiveCircuitBreakerDisabled { + if dirty { return nil } - if err := ensureStateBaseSchema(db); err != nil { - return err - } - if err := ensurePassiveCircuitBreakerColumn(db); err != nil { - return err - } - if err := ensureSubscriptionUsageInfoColumns(db); err != nil { - return err + switch version { + case stateVersionAddPassiveCircuitBreakerDisabled: + if err := ensureStateBaseSchema(db); err != nil { + return err + } + if err := ensurePassiveCircuitBreakerColumn(db); err != nil { + return err + } + if err := ensureSubscriptionUsageInfoColumns(db); err != nil { + return err + } + hasRegexExcludeFilters, err := hasTableColumn(db, "platforms", "regex_exclude_filters_json") + if err != nil { + return err + } + if hasRegexExcludeFilters { + return setMigrationVersion(driver, stateVersionAddRegexExcludeFilters) + } + return setMigrationVersion(driver, stateVersionAddSubscriptionUsageInfo) + case stateVersionAddSubscriptionUsageInfo: + hasRegexExcludeFilters, err := hasTableColumn(db, "platforms", "regex_exclude_filters_json") + if err != nil { + return err + } + if hasRegexExcludeFilters { + return setMigrationVersion(driver, stateVersionAddRegexExcludeFilters) + } } - return setMigrationVersion(driver, stateVersionAddSubscriptionUsageInfo) + return nil } func ensurePassiveCircuitBreakerColumn(db *sql.DB) error { diff --git a/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql new file mode 100644 index 00000000..93403e58 --- /dev/null +++ b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql @@ -0,0 +1 @@ +ALTER TABLE platforms DROP COLUMN regex_exclude_filters_json; diff --git a/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql new file mode 100644 index 00000000..fe93f2b3 --- /dev/null +++ b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql @@ -0,0 +1 @@ +ALTER TABLE platforms ADD COLUMN regex_exclude_filters_json TEXT NOT NULL DEFAULT '[]'; diff --git a/internal/state/repo_state.go b/internal/state/repo_state.go index d8317b28..01cc7282 100644 --- a/internal/state/repo_state.go +++ b/internal/state/repo_state.go @@ -106,6 +106,9 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { if _, err := platform.CompileRegexFilters(p.RegexFilters); err != nil { return err } + if _, err := platform.CompileRegexExcludeFilters(p.RegexExcludeFilters); err != nil { + return err + } if err := platform.ValidateRegionFilters(p.RegionFilters); err != nil { return err } @@ -140,6 +143,10 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { if err != nil { return fmt.Errorf("encode platform %s regex_filters: %w", p.ID, err) } + regexExcludeFiltersJSON, err := encodeStringSliceJSON(p.RegexExcludeFilters) + if err != nil { + return fmt.Errorf("encode platform %s regex_exclude_filters: %w", p.ID, err) + } regionFiltersJSON, err := encodeStringSliceJSON(p.RegionFilters) if err != nil { return fmt.Errorf("encode platform %s region_filters: %w", p.ID, err) @@ -149,15 +156,16 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { defer r.mu.Unlock() _, err = r.db.Exec(` - INSERT INTO platforms (id, name, sticky_ttl_ns, regex_filters_json, region_filters_json, + INSERT INTO platforms (id, name, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, sticky_ttl_ns = excluded.sticky_ttl_ns, regex_filters_json = excluded.regex_filters_json, + regex_exclude_filters_json = excluded.regex_exclude_filters_json, region_filters_json = excluded.region_filters_json, reverse_proxy_miss_action = excluded.reverse_proxy_miss_action, reverse_proxy_empty_account_behavior = excluded.reverse_proxy_empty_account_behavior, @@ -165,7 +173,7 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { allocation_policy = excluded.allocation_policy, passive_circuit_breaker_disabled = excluded.passive_circuit_breaker_disabled, updated_at_ns = excluded.updated_at_ns - `, p.ID, p.Name, p.StickyTTLNs, regexFiltersJSON, regionFiltersJSON, + `, p.ID, p.Name, p.StickyTTLNs, regexFiltersJSON, regexExcludeFiltersJSON, regionFiltersJSON, p.ReverseProxyMissAction, p.ReverseProxyEmptyAccountBehavior, p.ReverseProxyFixedAccountHeader, p.AllocationPolicy, p.PassiveCircuitBreakerDisabled, p.UpdatedAtNs) if err != nil { @@ -220,17 +228,17 @@ func (r *StateRepo) GetPlatformName(id string) (string, error) { // GetPlatform returns one platform by ID. func (r *StateRepo) GetPlatform(id string) (*model.Platform, error) { - row := r.db.QueryRow(`SELECT id, name, sticky_ttl_ns, regex_filters_json, region_filters_json, + row := r.db.QueryRow(`SELECT id, name, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns FROM platforms WHERE id = ?`, id) var p model.Platform - var regexFiltersJSON, regionFiltersJSON string + var regexFiltersJSON, regexExcludeFiltersJSON, regionFiltersJSON string var passiveCircuitBreakerDisabled int if err := row.Scan(&p.ID, &p.Name, &p.StickyTTLNs, ®exFiltersJSON, - ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, + ®exExcludeFiltersJSON, ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, &p.ReverseProxyFixedAccountHeader, &p.AllocationPolicy, &passiveCircuitBreakerDisabled, &p.UpdatedAtNs); err != nil { if err == sql.ErrNoRows { return nil, ErrNotFound @@ -242,18 +250,23 @@ func (r *StateRepo) GetPlatform(id string) (*model.Platform, error) { if err != nil { return nil, fmt.Errorf("decode platform %s regex_filters_json: %w", p.ID, err) } + regexExcludeFilters, err := decodeStringSliceJSON(regexExcludeFiltersJSON) + if err != nil { + return nil, fmt.Errorf("decode platform %s regex_exclude_filters_json: %w", p.ID, err) + } regionFilters, err := decodeStringSliceJSON(regionFiltersJSON) if err != nil { return nil, fmt.Errorf("decode platform %s region_filters_json: %w", p.ID, err) } p.RegexFilters = regexFilters + p.RegexExcludeFilters = regexExcludeFilters p.RegionFilters = regionFilters return &p, nil } // ListPlatforms returns all platforms. func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { - rows, err := r.db.Query("SELECT id, name, sticky_ttl_ns, regex_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns FROM platforms") + rows, err := r.db.Query("SELECT id, name, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns FROM platforms") if err != nil { return nil, err } @@ -262,10 +275,10 @@ func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { var result []model.Platform for rows.Next() { var p model.Platform - var regexFiltersJSON, regionFiltersJSON string + var regexFiltersJSON, regexExcludeFiltersJSON, regionFiltersJSON string var passiveCircuitBreakerDisabled int if err := rows.Scan(&p.ID, &p.Name, &p.StickyTTLNs, ®exFiltersJSON, - ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, + ®exExcludeFiltersJSON, ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, &p.ReverseProxyFixedAccountHeader, &p.AllocationPolicy, &passiveCircuitBreakerDisabled, &p.UpdatedAtNs); err != nil { return nil, err } @@ -274,11 +287,16 @@ func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { if err != nil { return nil, fmt.Errorf("decode platform %s regex_filters_json: %w", p.ID, err) } + regexExcludeFilters, err := decodeStringSliceJSON(regexExcludeFiltersJSON) + if err != nil { + return nil, fmt.Errorf("decode platform %s regex_exclude_filters_json: %w", p.ID, err) + } regionFilters, err := decodeStringSliceJSON(regionFiltersJSON) if err != nil { return nil, fmt.Errorf("decode platform %s region_filters_json: %w", p.ID, err) } p.RegexFilters = regexFilters + p.RegexExcludeFilters = regexExcludeFilters p.RegionFilters = regionFilters result = append(result, p) } diff --git a/internal/state/repo_state_test.go b/internal/state/repo_state_test.go index 0a3d2d7b..7b9ee22e 100644 --- a/internal/state/repo_state_test.go +++ b/internal/state/repo_state_test.go @@ -121,8 +121,8 @@ func TestMigrateStateDB_LegacyBaselineAdvancesToLatest(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddSubscriptionUsageInfo { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } if ok, err := hasTableColumn(db, "subscriptions", "incremental_alive_nodes"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.incremental_alive_nodes, ok=%v err=%v", ok, err) @@ -135,6 +135,73 @@ func TestMigrateStateDB_LegacyBaselineAdvancesToLatest(t *testing.T) { } } +func TestMigrateStateDB_LegacyBaselineWithRegexExcludeColumn(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/state.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + _, err = db.Exec(` + CREATE TABLE platforms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + sticky_ttl_ns INTEGER NOT NULL, + regex_filters_json TEXT NOT NULL DEFAULT '[]', + regex_exclude_filters_json TEXT NOT NULL DEFAULT '[]', + region_filters_json TEXT NOT NULL DEFAULT '[]', + reverse_proxy_miss_action TEXT NOT NULL DEFAULT 'RANDOM', + reverse_proxy_empty_account_behavior TEXT NOT NULL DEFAULT 'RANDOM', + reverse_proxy_fixed_account_header TEXT NOT NULL DEFAULT '', + allocation_policy TEXT NOT NULL DEFAULT 'BALANCED', + passive_circuit_breaker_disabled INTEGER NOT NULL DEFAULT 0, + updated_at_ns INTEGER NOT NULL + ); + + CREATE TABLE subscriptions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'remote', + url TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + update_interval_ns INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + ephemeral INTEGER NOT NULL DEFAULT 0, + incremental_alive_nodes INTEGER NOT NULL DEFAULT 0, + ephemeral_node_evict_delay_ns INTEGER NOT NULL, + usage_upload_bytes INTEGER NOT NULL DEFAULT 0, + usage_download_bytes INTEGER NOT NULL DEFAULT 0, + usage_total_bytes INTEGER NOT NULL DEFAULT 0, + usage_expire_unix INTEGER NOT NULL DEFAULT 0, + usage_updated_at_ns INTEGER NOT NULL DEFAULT 0, + created_at_ns INTEGER NOT NULL, + updated_at_ns INTEGER NOT NULL + ) + `) + if err != nil { + t.Fatalf("create latest-like legacy tables: %v", err) + } + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("MigrateStateDB: %v", err) + } + + requireTableColumn(t, db, "platforms", "regex_exclude_filters_json") + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatalf("schema_migrations dirty=true") + } + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } +} + func TestMigrateStateDB_AddsIncrementalAliveNodesToLegacySubscriptions(t *testing.T) { dir := t.TempDir() db, err := OpenDB(dir + "/state.db") @@ -191,8 +258,8 @@ func TestMigrateStateDB_AddsIncrementalAliveNodesToLegacySubscriptions(t *testin if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddSubscriptionUsageInfo { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) @@ -269,8 +336,8 @@ func TestMigrateStateDB_NormalizesLegacyRandomMissAction(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddSubscriptionUsageInfo { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } if ok, err := hasTableColumn(db, "subscriptions", "incremental_alive_nodes"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.incremental_alive_nodes, ok=%v err=%v", ok, err) @@ -314,8 +381,8 @@ func TestMigrateStateDB_RepairsForkVersion6SubscriptionUsage(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddSubscriptionUsageInfo { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } } @@ -358,8 +425,40 @@ func TestMigrateStateDB_RepairsUpstreamVersion6PassiveCircuitBreaker(t *testing. if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddSubscriptionUsageInfo { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddSubscriptionUsageInfo) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } +} + +func TestMigrateStateDB_RepairsVersion7WithRegexExcludeColumn(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/state.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("initial MigrateStateDB: %v", err) + } + forceStateMigrationVersion(t, db, stateVersionAddSubscriptionUsageInfo) + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("MigrateStateDB: %v", err) + } + + requireTableColumn(t, db, "platforms", "regex_exclude_filters_json") + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatalf("schema_migrations dirty=true") + } + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } } @@ -573,6 +672,13 @@ func TestStateRepo_Platform_ValidationRejectsInvalidRegex(t *testing.T) { t.Fatal("expected error for uncompilable regex") } + // Uncompilable exclude regex. + bad = base + bad.RegexExcludeFilters = []string{"(unclosed"} + if err := repo.UpsertPlatform(bad); err == nil { + t.Fatal("expected error for uncompilable exclude regex") + } + // Invalid region_filters. bad = base bad.RegionFilters = []string{""} @@ -582,6 +688,7 @@ func TestStateRepo_Platform_ValidationRejectsInvalidRegex(t *testing.T) { // Valid config should still succeed. base.RegexFilters = []string{"^ss$", "vmess"} + base.RegexExcludeFilters = []string{"bad"} base.RegionFilters = []string{"us", "jp"} if err := repo.UpsertPlatform(base); err != nil { t.Fatalf("valid platform rejected: %v", err) @@ -592,6 +699,9 @@ func TestStateRepo_Platform_ValidationRejectsInvalidRegex(t *testing.T) { if len(list) != 1 { t.Fatalf("expected 1 platform, got %d", len(list)) } + if !reflect.DeepEqual(list[0].RegexExcludeFilters, []string{"bad"}) { + t.Fatalf("regex_exclude_filters = %v, want [bad]", list[0].RegexExcludeFilters) + } } func TestStateRepo_Platform_ValidationRejectsInvalidName(t *testing.T) { diff --git a/webui/src/features/platforms/PlatformDetailPage.tsx b/webui/src/features/platforms/PlatformDetailPage.tsx index c815203e..e1a0984a 100644 --- a/webui/src/features/platforms/PlatformDetailPage.tsx +++ b/webui/src/features/platforms/PlatformDetailPage.tsx @@ -212,6 +212,7 @@ export function PlatformDetailPage() { const stickyTTL = platform ? formatGoDuration(platform.sticky_ttl, t("默认")) : t("默认"); const regionCount = platform?.region_filters.length ?? 0; const regexCount = platform?.regex_filters.length ?? 0; + const regexExcludeCount = platform?.regex_exclude_filters.length ?? 0; const deleteDisabled = !platform || platform.id === ZERO_UUID || deleteMutation.isPending; return ( @@ -280,6 +281,10 @@ export function PlatformDetailPage() { {t("正则")} {regexCount} + + {t("正则排除")} + {regexExcludeCount} + {t("租约时长")} {stickyTTL} @@ -499,6 +504,26 @@ export function PlatformDetailPage() {

+
+ +