mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 23:56:36 +08:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b64542c94 | |||
| 032b0f0cfd | |||
| 7008717a49 | |||
| 8552a70355 | |||
| b2454e86c9 | |||
| 312c9a9c5c | |||
| e1324b8c8c | |||
| c034d0d41f | |||
| fd3ecc38ef | |||
| 2eee506716 | |||
| abf13bdac9 | |||
| f0facf6703 | |||
| 583a3834f9 | |||
| bd13477fa3 | |||
| 106a30bf9d | |||
| 465815cf34 | |||
| ec9fb77eb5 | |||
| 7d63dd4cc3 | |||
| 4cfa6adee7 | |||
| bc8f2ec8a1 | |||
| 9a37c2f603 | |||
| ff6d46ddaf | |||
| 723534faea | |||
| 73490a9be6 | |||
| 5a327459f7 | |||
| f307e7d5eb | |||
| fdd72979b6 | |||
| ad33791a26 | |||
| 6320b1f0c1 | |||
| 91d79b6b3a | |||
| fc7df6bd64 | |||
| 25dfb84324 | |||
| 4ebd6703fe | |||
| 1f53a39784 | |||
| 5ebd4c2a91 | |||
| 6c93d829c6 | |||
| 5d22d4cb06 |
@@ -1,6 +1,6 @@
|
||||
# FLVX
|
||||
|
||||
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||
> **联系我们**: [Telegram群组](https://t.me/flvxchannel)
|
||||
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -15,10 +15,15 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/opt/flvx-panel
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
|
||||
@@ -15,10 +15,15 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/opt/flvx-panel
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# Proxy Protocol 传输分析报告
|
||||
|
||||
**日期**: 2026-05-07
|
||||
**测试环境**: 20.118.172.127 (Server 1) ↔ 108.181.90.137 (Server 2)
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码流程分析
|
||||
|
||||
### 完整数据链路
|
||||
|
||||
```
|
||||
前端 (proxyProtocol: 0|1|2)
|
||||
→ 后端 handler mutations.go:1936
|
||||
→ 数据库存储 forward.proxy_protocol (model.go:50)
|
||||
→ 控制面 buildForwardServiceConfigs (control_plane.go:1791-1796)
|
||||
→ handler metadata: {"proxyProtocol": 2}
|
||||
→ Agent metadata 解析 (metadata.go:42)
|
||||
→ handler.go:256 WrapClientConn()
|
||||
→ conn.go:14 HeaderProxyFromAddrs(byte(ppv), src, dst)
|
||||
→ conn.go:15 header.WriteTo(c)
|
||||
→ 目标服务器收到 PROXY protocol header
|
||||
```
|
||||
|
||||
### 关键代码
|
||||
|
||||
**写入 PROXY header** (`go-gost/x/internal/net/proxyproto/conn.go`):
|
||||
```go
|
||||
func WrapClientConn(ppv int, src, dst net.Addr, c net.Conn) net.Conn {
|
||||
if ppv <= 0 {
|
||||
return c
|
||||
}
|
||||
header := proxyproto.HeaderProxyFromrs(byte(ppv), src, dst)
|
||||
header.WriteTo(c)
|
||||
return c
|
||||
}
|
||||
```
|
||||
|
||||
**Handler 调用** (`go-gost/x/handler/forward/local/handler.go:256`):
|
||||
```go
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), conn.LocalAddr(), cc)
|
||||
```
|
||||
|
||||
- `src` = `conn.RemoteAddr()` → 客户端真实 IP ✅
|
||||
- `dst` = `conn.LocalAddr()` → agent 监听地址 ✅
|
||||
- `ppv` = 1 或 2 → 版本号正确 ✅
|
||||
|
||||
---
|
||||
|
||||
## 2. 实际传输测试结果
|
||||
|
||||
### 测试方法
|
||||
|
||||
1. 在 Server 2 启动 Python TCP 监听器,解析 PROXY protocol header
|
||||
2. 在 Server 1 用当前代码编译 gost,配置 `proxyProtocol: 2` 转发到 Server 2
|
||||
3. 通过 `nc` 发送测试数据,验证 Server 2 是否收到正确的 PROXY header
|
||||
|
||||
### 测试结果
|
||||
|
||||
| 版本 | 状态 | 接收到的 Header |
|
||||
|------|------|----------------|
|
||||
| **PPv2** | ✅ 成功 | `PP2 family=1 alen=12 SRC=127.0.0.1:45410 DST=127.0.0.1:20001` |
|
||||
| **PPv1** | ✅ 成功 | `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001` |
|
||||
|
||||
### 测试详情
|
||||
|
||||
**PPv2 原始数据**:
|
||||
```
|
||||
Got 28 bytes
|
||||
PP2 family=1 alen=12
|
||||
SRC=127.0.0.1:45410 DST=127.0.0.1:20001
|
||||
```
|
||||
|
||||
**PPv1 原始数据** (hex):
|
||||
```
|
||||
50524f58592054435034203132372e302e302e31203132372e302e302e312034333831362032303030310d0a
|
||||
```
|
||||
解码: `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001`
|
||||
|
||||
---
|
||||
|
||||
## 3. 单元测试结果
|
||||
|
||||
```
|
||||
go-gost/x/handler/forward/local/ → TestLocalForwardHandlerSendsProxyProtocolToTarget ✅
|
||||
go-backend/internal/http/handler/ → TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler ✅
|
||||
go-backend/internal/store/repo/ → TestGetForwardRecordIncludesProxyProtocol ✅
|
||||
```
|
||||
|
||||
全部通过 (3/3)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 发现的问题
|
||||
|
||||
### 问题 1: `WriteTo` 错误未检查
|
||||
|
||||
**位置**: `go-gost/x/internal/net/proxyproto/conn.go:15`
|
||||
|
||||
```go
|
||||
header.WriteTo(c) // 返回 (int64, error) 被忽略
|
||||
```
|
||||
|
||||
**影响**: 如果写入失败(连接已断开、网络错误等),后续数据传输会在没有 PROXY header 的情况下继续,目标服务器可能解析出错。
|
||||
|
||||
**建议**:
|
||||
```go
|
||||
if _, err := header.WriteTo(c); err != nil {
|
||||
return c // 或包装一个带错误的 conn
|
||||
}
|
||||
```
|
||||
|
||||
**严重程度**: 低(实际场景中,写入失败后 `Transport` 也会很快失败)
|
||||
|
||||
---
|
||||
|
||||
### 问题 2: 部署版本过旧
|
||||
|
||||
**服务器状态**:
|
||||
|
||||
| 服务器 | 组件 | 版本 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| 20.118.172.127 | flux_agent | UPX 压缩,无法读取版本 | ✅ 运行中 |
|
||||
| 20.118.172.127 | paneld | `/app/paneld` | ✅ 运行中 |
|
||||
| 20.118.172.127 | /usr/local/bin/gost | v3.0.0 (go1.23.4) | 旧版,不支持 handler metadata 中的 proxyProtocol |
|
||||
| 108.181.90.137 | flux_agent | 8.8MB | ✅ 运行中 |
|
||||
|
||||
**影响**: 旧版 gost 二进制不识别 handler metadata 中的 `proxyProtocol` 字段,PROXY protocol 功能在生产环境不可用。
|
||||
|
||||
**验证**: 用旧版 gost 测试时,目标服务器收到的原始数据为空,无 PROXY header。
|
||||
|
||||
---
|
||||
|
||||
### 问题 3: 数据库 Schema 缺失
|
||||
|
||||
**位置**: 20.118.172.127 的 `/app/data/gost.db`
|
||||
|
||||
**当前 forward 表 schema**:
|
||||
```sql
|
||||
CREATE TABLE `forward` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`user_id` integer NOT NULL,
|
||||
`user_name` varchar(100) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`tunnel_id` integer NOT NULL,
|
||||
`remote_addr` text NOT NULL,
|
||||
`strategy` varchar(100) NOT NULL DEFAULT "fifo",
|
||||
`in_flow` integer NOT NULL DEFAULT 0,
|
||||
`out_flow` integer NOT NULL DEFAULT 0,
|
||||
`created_time` integer NOT NULL,
|
||||
`updated_time` integer NOT NULL,
|
||||
`status` integer NOT NULL,
|
||||
`inx` integer NOT NULL DEFAULT 0,
|
||||
`speed_id` integer
|
||||
);
|
||||
```
|
||||
|
||||
**缺失字段**:
|
||||
- `proxy_protocol` — PROXY protocol 版本
|
||||
- `max_conn` — 最大连接数
|
||||
- `ip_max_conn` — 每 IP 最大连接数
|
||||
- `ip_speed_id` — 每 IP 限速 ID
|
||||
|
||||
**影响**: 后端无法存储和读取 proxy_protocol 配置,前端设置不会生效。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论
|
||||
|
||||
| 维度 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| **代码实现** | ✅ 正确 | 完整的写入链路,版本/地址正确 |
|
||||
| **单元测试** | ✅ 通过 | 3/3 测试覆盖 handler、repo、控制面 |
|
||||
| **实际传输 (新编译版)** | ✅ 成功 | PPv1 和 PPv2 均正确传输 |
|
||||
| **实际传输 (部署版)** | ❌ 不工作 | 旧版不支持 handler metadata 中的 proxyProtocol |
|
||||
| **数据库 Schema** | ❌ 缺字段 | 需要迁移添加 proxy_protocol 等列 |
|
||||
|
||||
**总结**: 代码实现正确,PROXY protocol 传输逻辑无误。但生产服务器运行的是旧版本,需要升级 backend 和 agent 才能启用此功能。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
||||
# Dependabot Remediation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Resolve the open Dependabot dependency alerts for `go-backend`, `vite-frontend`, `go-gost/x`, and `go-gost` without mixing in unrelated business security changes.
|
||||
|
||||
**Architecture:** Apply targeted dependency upgrades per module, verify each module before moving to the next, and keep commits scoped to one dependency group. `go-gost/x` is fixed before `go-gost` because the main agent module uses `replace github.com/go-gost/x => ./x`.
|
||||
|
||||
**Tech Stack:** Go modules, pnpm, Vite/Rolldown, GitHub CLI Dependabot alerts API.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `go-backend/go.mod`
|
||||
Responsibility: update `github.com/jackc/pgx/v5` to the patched version.
|
||||
- Modify: `go-backend/go.sum`
|
||||
Responsibility: reflect Go module checksum changes from the pgx upgrade.
|
||||
- Modify: `vite-frontend/package.json`
|
||||
Responsibility: update direct vulnerable npm dependency versions and configure `pnpm.overrides`.
|
||||
- Modify: `vite-frontend/pnpm-lock.yaml`
|
||||
Responsibility: resolve vulnerable npm transitive dependencies to patched versions.
|
||||
- Modify: `go-gost/x/go.mod`
|
||||
Responsibility: update vulnerable Go dependencies used by the local `github.com/go-gost/x` module.
|
||||
- Modify: `go-gost/x/go.sum`
|
||||
Responsibility: reflect checksum changes for `go-gost/x`.
|
||||
- Modify: `go-gost/x/dialer/dtls/dialer.go`
|
||||
Responsibility: migrate DTLS import path from `github.com/pion/dtls/v2` to `github.com/pion/dtls/v3`.
|
||||
- Modify: `go-gost/x/listener/dtls/listener.go`
|
||||
Responsibility: migrate DTLS import path from `github.com/pion/dtls/v2` to `github.com/pion/dtls/v3`.
|
||||
- Modify: `go-gost/go.mod`
|
||||
Responsibility: sync vulnerable dependency versions for the main agent module while preserving local `replace github.com/go-gost/x => ./x`.
|
||||
- Modify: `go-gost/go.sum`
|
||||
Responsibility: reflect checksum changes for the main agent module.
|
||||
|
||||
## Task 1: Capture Baseline Alerts
|
||||
|
||||
**Files:**
|
||||
- Read: GitHub Dependabot alerts API
|
||||
- Read: `go-backend/go.mod`
|
||||
- Read: `vite-frontend/package.json`
|
||||
- Read: `go-gost/x/go.mod`
|
||||
- Read: `go-gost/go.mod`
|
||||
|
||||
- [ ] **Step 1: Query current open Dependabot alerts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh api 'repos/Sagit-chu/flvx/dependabot/alerts?state=open&per_page=100' --paginate \
|
||||
--jq '.[] | [.number,.security_advisory.severity,.dependency.package.ecosystem,.dependency.manifest_path,.dependency.package.name,.security_vulnerability.vulnerable_version_range,(.security_vulnerability.first_patched_version.identifier // "")] | @tsv'
|
||||
```
|
||||
|
||||
Expected: output includes alerts for `github.com/jackc/pgx/v5`, `postcss`, `serialize-javascript`, `fast-uri`, `@babel/plugin-transform-modules-systemjs`, `github.com/sirupsen/logrus`, `github.com/quic-go/quic-go`, `github.com/quic-go/webtransport-go`, and `github.com/pion/dtls/v2`.
|
||||
|
||||
- [ ] **Step 2: Confirm starting versions in module manifests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'jackc/pgx|postcss|serialize-javascript|pion/dtls|quic-go|webtransport-go|sirupsen/logrus' \
|
||||
go-backend/go.mod vite-frontend/package.json go-gost/x/go.mod go-gost/go.mod
|
||||
```
|
||||
|
||||
Expected key lines:
|
||||
|
||||
```text
|
||||
go-backend/go.mod: github.com/jackc/pgx/v5 v5.7.3
|
||||
vite-frontend/package.json: "postcss": "8.5.6"
|
||||
vite-frontend/package.json: "serialize-javascript": "7.0.3"
|
||||
go-gost/x/go.mod: github.com/pion/dtls/v2 v2.2.6
|
||||
go-gost/x/go.mod: github.com/quic-go/quic-go v0.49.1
|
||||
go-gost/x/go.mod: github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66
|
||||
go-gost/x/go.mod: github.com/sirupsen/logrus v1.8.1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Confirm the DTLS advisory has no patched v2 release**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go list -m -versions github.com/pion/dtls/v2
|
||||
gh api 'advisories/GHSA-9f3f-wv7r-qc8r' --jq '{summary, vulnerabilities}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
github.com/pion/dtls/v2 ... v2.2.12
|
||||
```
|
||||
|
||||
Expected advisory facts:
|
||||
|
||||
```text
|
||||
github.com/pion/dtls/v2 vulnerable range <= 2.2.12 has no first_patched_version.
|
||||
github.com/pion/dtls/v3 patched versions include 3.0.11 and 3.1.1.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Do not commit baseline capture**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no files are changed by Task 1.
|
||||
|
||||
## Task 2: Fix go-backend pgx Alerts
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/go.mod`
|
||||
- Modify: `go-backend/go.sum`
|
||||
|
||||
- [ ] **Step 1: Upgrade pgx to the patched version**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go get github.com/jackc/pgx/v5@v5.9.2)
|
||||
```
|
||||
|
||||
Expected: `go-backend/go.mod` changes `github.com/jackc/pgx/v5` from `v5.7.3` to `v5.9.2`, and `go-backend/go.sum` updates checksums.
|
||||
|
||||
- [ ] **Step 2: Tidy backend module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go mod tidy)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify backend dependency version**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/jackc/pgx/v5' go-backend/go.mod
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
go-backend/go.mod: github.com/jackc/pgx/v5 v5.9.2
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
```
|
||||
|
||||
Expected: all backend packages pass.
|
||||
|
||||
- [ ] **Step 5: Commit backend dependency fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-backend/go.mod go-backend/go.sum
|
||||
git commit -m "fix: update backend pgx dependency"
|
||||
```
|
||||
|
||||
Expected: one commit containing only `go-backend/go.mod` and `go-backend/go.sum`.
|
||||
|
||||
## Task 3: Fix Frontend npm Alerts
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/package.json`
|
||||
- Modify: `vite-frontend/pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Update direct dependency and pnpm overrides in package.json**
|
||||
|
||||
Edit `vite-frontend/package.json` so the relevant entries are exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"postcss": "8.5.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@babel/plugin-transform-modules-systemjs": "7.29.4",
|
||||
"fast-uri": "3.1.2",
|
||||
"serialize-javascript": "7.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the existing top-level `"overrides"` block after adding `"pnpm.overrides"`. Keep all other existing dependencies and scripts unchanged.
|
||||
|
||||
- [ ] **Step 2: Regenerate pnpm lockfile**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm install)
|
||||
```
|
||||
|
||||
Expected: `vite-frontend/pnpm-lock.yaml` updates and install exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify vulnerable npm versions are absent**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'postcss@8\.5\.[0-9]:|"postcss":\s*"8\.5\.[0-9]"|serialize-javascript@[0-6]\.|serialize-javascript@7\.0\.[0-4]|fast-uri@3\.1\.[0-1]|plugin-transform-modules-systemjs@7\.29\.[0-3]' vite-frontend/pnpm-lock.yaml vite-frontend/package.json
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Verify patched npm versions are present**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'postcss@8\.5\.10|serialize-javascript@7\.0\.5|fast-uri@3\.1\.2|plugin-transform-modules-systemjs@7\.29\.4' vite-frontend/pnpm-lock.yaml vite-frontend/package.json
|
||||
```
|
||||
|
||||
Expected: output includes patched entries for `postcss@8.5.10`, `serialize-javascript@7.0.5`, `fast-uri@3.1.2`, and `@babel/plugin-transform-modules-systemjs@7.29.4`.
|
||||
|
||||
- [ ] **Step 5: Build frontend**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
Expected: TypeScript and Rolldown/Vite build complete successfully.
|
||||
|
||||
- [ ] **Step 6: Commit frontend dependency fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add vite-frontend/package.json vite-frontend/pnpm-lock.yaml
|
||||
git commit -m "fix: update frontend vulnerable dependencies"
|
||||
```
|
||||
|
||||
Expected: one commit containing only `vite-frontend/package.json` and `vite-frontend/pnpm-lock.yaml`.
|
||||
|
||||
## Task 4: Fix go-gost/x Non-DTLS Alerts
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/x/go.mod`
|
||||
- Modify: `go-gost/x/go.sum`
|
||||
|
||||
- [ ] **Step 1: Upgrade non-DTLS vulnerable Go dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go get github.com/sirupsen/logrus@v1.8.3 github.com/quic-go/quic-go@v0.57.0 github.com/quic-go/webtransport-go@v0.10.0)
|
||||
```
|
||||
|
||||
Expected: `go-gost/x/go.mod` resolves these dependencies to at least:
|
||||
|
||||
```text
|
||||
github.com/sirupsen/logrus v1.8.3
|
||||
github.com/quic-go/quic-go v0.57.0
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tidy go-gost/x module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go mod tidy)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify go-gost/x non-DTLS dependency versions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/sirupsen/logrus|github.com/quic-go/quic-go|github.com/quic-go/webtransport-go' go-gost/x/go.mod
|
||||
```
|
||||
|
||||
Expected output contains versions at or above:
|
||||
|
||||
```text
|
||||
github.com/sirupsen/logrus v1.8.3
|
||||
github.com/quic-go/quic-go v0.57.0
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run go-gost/x tests after non-DTLS upgrades**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0. If it fails, stop this task before committing and inspect the first compiler error. The only permitted follow-up edits in this task are direct API-compatibility changes in files named by the compiler under `go-gost/x`; rerun this command after each edit.
|
||||
|
||||
- [ ] **Step 5: Commit go-gost/x non-DTLS dependency fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-gost/x/go.mod go-gost/x/go.sum
|
||||
git commit -m "fix: update gost quic dependencies"
|
||||
```
|
||||
|
||||
Expected: one commit containing `go-gost/x/go.mod` and `go-gost/x/go.sum`, plus only the compiler-named `go-gost/x` files edited during Step 4.
|
||||
|
||||
## Task 5: Migrate go-gost/x DTLS From v2 To v3
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/x/go.mod`
|
||||
- Modify: `go-gost/x/go.sum`
|
||||
- Modify: `go-gost/x/dialer/dtls/dialer.go`
|
||||
- Modify: `go-gost/x/listener/dtls/listener.go`
|
||||
|
||||
- [ ] **Step 1: Update DTLS imports**
|
||||
|
||||
In `go-gost/x/dialer/dtls/dialer.go`, change:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v2"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v3"
|
||||
```
|
||||
|
||||
In `go-gost/x/listener/dtls/listener.go`, change:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v2"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v3"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add patched DTLS v3 module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go get github.com/pion/dtls/v3@v3.0.11)
|
||||
```
|
||||
|
||||
Expected: `go-gost/x/go.mod` contains `github.com/pion/dtls/v3 v3.0.11` and no longer needs `github.com/pion/dtls/v2`.
|
||||
|
||||
- [ ] **Step 3: Tidy and format go-gost/x**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go mod tidy)
|
||||
gofmt -w go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 4: Verify v2 import and module are removed**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/pion/dtls/v2' go-gost/x
|
||||
rg -n 'github.com/pion/dtls/v3' go-gost/x/go.mod go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
first command: no output
|
||||
second command: output includes go.mod, dialer.go, and listener.go
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run go-gost/x tests after DTLS migration**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0. If the compiler reports DTLS v3 API errors, edit only `go-gost/x/dialer/dtls/dialer.go` and `go-gost/x/listener/dtls/listener.go`, preserving the existing `dtls.Config`, `dtls.ClientWithContext`, and `dtls.Listen` flow, then rerun this command.
|
||||
|
||||
- [ ] **Step 6: Commit DTLS migration**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-gost/x/go.mod go-gost/x/go.sum go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
|
||||
git commit -m "fix: migrate gost dtls dependency"
|
||||
```
|
||||
|
||||
Expected: one commit containing the DTLS import migration and Go module updates.
|
||||
|
||||
## Task 6: Sync go-gost Main Module
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/go.mod`
|
||||
- Modify: `go-gost/go.sum`
|
||||
|
||||
- [ ] **Step 1: Upgrade main module vulnerable dependency requirements**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go get github.com/sirupsen/logrus@v1.8.3 github.com/quic-go/quic-go@v0.57.0 github.com/quic-go/webtransport-go@v0.10.0 github.com/pion/dtls/v3@v3.0.11)
|
||||
```
|
||||
|
||||
Expected: `go-gost/go.mod` resolves vulnerable dependencies to patched versions and preserves this replace directive:
|
||||
|
||||
```go
|
||||
replace github.com/go-gost/x => ./x
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tidy main agent module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go mod tidy)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify go-gost no longer references vulnerable DTLS v2**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/pion/dtls/v2' go-gost/go.mod go-gost/go.sum
|
||||
rg -n 'github.com/pion/dtls/v3|github.com/quic-go/quic-go|github.com/quic-go/webtransport-go|github.com/sirupsen/logrus|replace github.com/go-gost/x => ./x' go-gost/go.mod
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
first command: no output
|
||||
second command: output includes dtls/v3, quic-go, webtransport-go, logrus, and the local replace directive
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run go-gost tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go test ./...)
|
||||
```
|
||||
|
||||
Expected: all packages pass.
|
||||
|
||||
- [ ] **Step 5: Build go-gost binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go build .)
|
||||
```
|
||||
|
||||
Expected: build exits with code 0.
|
||||
|
||||
- [ ] **Step 6: Commit go-gost module sync**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-gost/go.mod go-gost/go.sum
|
||||
git commit -m "fix: sync gost main dependencies"
|
||||
```
|
||||
|
||||
Expected: one commit containing only `go-gost/go.mod` and `go-gost/go.sum`.
|
||||
|
||||
## Task 7: Final Dependabot Verification
|
||||
|
||||
**Files:**
|
||||
- Read: GitHub Dependabot alerts API
|
||||
- Read: Git working tree status
|
||||
|
||||
- [ ] **Step 1: Run all verification commands once more**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
(cd go-gost/x && go test ./...)
|
||||
(cd go-gost && go test ./...)
|
||||
(cd go-gost && go build .)
|
||||
```
|
||||
|
||||
Expected: every command exits with code 0.
|
||||
|
||||
- [ ] **Step 2: Query open Dependabot alerts after dependency updates**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh api 'repos/Sagit-chu/flvx/dependabot/alerts?state=open&per_page=100' --paginate \
|
||||
--jq 'group_by(.security_advisory.severity) | map({severity:.[0].security_advisory.severity,count:length})'
|
||||
```
|
||||
|
||||
Expected: counts are lower than the baseline from Task 1. If Dependabot has not rescanned yet, run the detailed query from Task 1 and confirm the manifest files now contain patched versions locally.
|
||||
|
||||
- [ ] **Step 3: Confirm no vulnerable dependency strings remain in manifests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/jackc/pgx/v5 v5\.7\.3|postcss\"\\s*:\\s*\"8\.5\.6|serialize-javascript\"\\s*:\\s*\"7\.0\.3|github.com/pion/dtls/v2|github.com/quic-go/quic-go v0\.49\.1|github.com/quic-go/webtransport-go v0\.8\.1|github.com/sirupsen/logrus v1\.8\.1' \
|
||||
go-backend/go.mod vite-frontend/package.json go-gost/x/go.mod go-gost/go.mod
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Confirm working tree contains only intentional changes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no uncommitted files from this Dependabot remediation remain. Pre-existing unrelated files may still appear; do not stage or revert them.
|
||||
@@ -0,0 +1,295 @@
|
||||
# 面板本体一键升级设计
|
||||
|
||||
**日期**: 2026-05-04
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
在 FLVX 管理面板中增加“面板升级”能力,使管理员可以在网页上检查 GitHub Release 并触发面板本体升级。目标是升级整套面板,而不是只升级转发节点或只替换后端二进制。
|
||||
|
||||
本设计采用 Docker Compose 整套升级方案:后端容器通过受限的 Docker socket 能力更新宿主机部署目录中的 `docker-compose.yml` 和 `.env`,然后启动独立的升级 helper 容器,由 helper 拉取新版 backend/frontend 镜像并重新启动 `backend` 与 `frontend` 服务。
|
||||
|
||||
## sub2api 参考结论
|
||||
|
||||
sub2api 的一键升级不是在宿主机执行 `docker compose pull/up`。它的运行形态是单体 Go 服务:前端构建产物 embed 到后端二进制,容器内只运行 `/app/sub2api`。升级接口下载 GitHub Release 中匹配当前系统和架构的 `sub2api_<version>_<os>_<arch>.tar.gz` 以及 `checksums.txt`,校验后把当前 `os.Executable()` 指向的 `/app/sub2api` 改名为 `/app/sub2api.backup`,再把新二进制原子替换到原路径。重启接口延迟调用 `os.Exit(0)`,依赖 Docker Compose 的 `restart: unless-stopped` 拉起同一个容器。
|
||||
|
||||
这种方式在 sub2api 的 Docker 部署中可行,是因为它的前后端在同一个二进制里。FLVX 当前是 `flux-panel-backend` 与 `vite-frontend` 两个容器,替换 `/app/paneld` 只能升级后端,不能升级前端页面。因此 FLVX 的“面板本体升级”需要更新 Compose 版本和两个镜像,而不是照搬二进制替换。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 管理员可在面板上查看当前版本、最新版本、升级通道和升级能力状态。
|
||||
2. 管理员可一键升级整套面板 backend/frontend。
|
||||
3. 升级复用现有 GitHub Release 和 `FLUX_VERSION` 版本机制。
|
||||
4. 升级复用现有 GitHub 加速配置 `github_proxy_enabled` / `github_proxy_url`。
|
||||
5. 升级过程不接受任意命令、任意 URL 或任意 compose 路径。
|
||||
6. 环境不满足时清晰提示不可用原因,不静默失败。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不实现 sub2api 式后端二进制替换作为本次主路径。
|
||||
2. 不支持从非本仓库 Release 下载升级资产。
|
||||
3. 不支持普通用户触发升级。
|
||||
4. 不支持在前端执行 shell 命令。
|
||||
5. 不修改 `install.sh` 或 `panel_install.sh` 的本地安装菜单逻辑;发布流程仍可能覆盖这些脚本。
|
||||
6. 不引入前端测试框架。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
- `go-backend/internal/http/handler/upgrade.go`
|
||||
- 新增 `go-backend/internal/http/handler/system_upgrade.go`
|
||||
- 新增 `go-backend/internal/http/handler/system_upgrade_test.go`
|
||||
- `go-backend/Dockerfile`
|
||||
|
||||
### 部署模板
|
||||
|
||||
- `docker-compose-v4.yml`
|
||||
- `docker-compose-v6.yml`
|
||||
|
||||
### 前端
|
||||
|
||||
- `vite-frontend/src/api/index.ts`
|
||||
- `vite-frontend/src/api/types.ts`
|
||||
- `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
## 运行前提
|
||||
|
||||
升级能力仅在 Docker Compose 部署中可用,并要求后端容器具备以下条件:
|
||||
|
||||
1. 容器内存在 Docker CLI,且支持 `docker compose version`。
|
||||
2. `/var/run/docker.sock` 挂载到后端容器。
|
||||
3. 宿主部署目录挂载到容器内固定路径,例如 `/opt/flvx-panel`。
|
||||
4. 环境变量 `PANEL_DEPLOY_DIR=/opt/flvx-panel`。
|
||||
5. 环境变量 `PANEL_BACKEND_CONTAINER=flux-panel-backend`,为空时默认使用 `flux-panel-backend`;值必须匹配容器名安全字符集 `[A-Za-z0-9_.-]+`。
|
||||
6. 部署目录内存在 `.env` 和 `docker-compose.yml`。
|
||||
|
||||
如果任一条件不满足,检查接口返回 `capable=false` 和明确的 `reason`,升级按钮禁用。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### API
|
||||
|
||||
新增系统升级接口,路径使用 `/api/v1/system/*`,继续受现有 middleware 管控,仅管理员可访问。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/v1/system/version` | 返回当前版本、升级通道、能力状态和可选最新版本 |
|
||||
| `POST` | `/api/v1/system/check-updates` | 强制查询 GitHub Release,返回最新版本和候选列表 |
|
||||
| `POST` | `/api/v1/system/upgrade` | 执行升级 |
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "stable",
|
||||
"version": ""
|
||||
}
|
||||
```
|
||||
|
||||
`channel` 使用现有节点升级的通道语义:`stable` 匹配纯数字版本,`dev` 匹配 `alpha` / `beta` / `rc`。`version` 为空时自动选择该通道最新 Release。
|
||||
|
||||
`/api/v1/system/version` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"currentVersion": "2.1.9-beta14",
|
||||
"channel": "stable",
|
||||
"latestVersion": "2.1.9",
|
||||
"hasUpdate": true,
|
||||
"capable": true,
|
||||
"reason": "",
|
||||
"deployDir": "/opt/flvx-panel",
|
||||
"composeFile": "/opt/flvx-panel/docker-compose.yml",
|
||||
"backendContainer": "flux-panel-backend"
|
||||
}
|
||||
```
|
||||
|
||||
`/api/v1/system/upgrade` 成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.1.9",
|
||||
"message": "升级 helper 已启动,面板服务将短暂重启",
|
||||
"commands": [
|
||||
"docker run -d --rm --volumes-from flux-panel-backend ...",
|
||||
"docker compose pull backend frontend",
|
||||
"docker compose up -d backend frontend"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
返回的 `commands` 只用于 UI 展示固定步骤,不包含用户输入或 shell 拼接结果。
|
||||
|
||||
### 版本来源
|
||||
|
||||
当前版本优先从容器环境变量读取:
|
||||
|
||||
1. `FLUX_VERSION`
|
||||
2. `VITE_APP_VERSION` 不在后端容器中可靠存在,不作为后端版本来源。
|
||||
3. 为空时返回 `dev`。
|
||||
|
||||
发布流程已经在 `panel_install.sh` 写入 `.env` 的 `FLUX_VERSION`,Compose 模板需要把该变量传给 backend 容器,保证后端可感知当前版本。
|
||||
|
||||
### Release 查询
|
||||
|
||||
复用现有 `fetchGitHubReleases`、`resolveLatestReleaseByChannel`、`normalizeReleaseChannel`、`releaseChannelFromTag`、`releaseChannelLabel` 和 GitHub 加速配置能力。新增函数只负责筛选系统升级所需资产:
|
||||
|
||||
- `docker-compose-v4.yml`
|
||||
- `docker-compose-v6.yml`
|
||||
|
||||
是否下载 v4/v6 compose 文件通过当前部署目录中的 `docker-compose.yml` 判断:如果网络定义包含 `enable_ipv6: true`,选择 `docker-compose-v6.yml`;否则选择 `docker-compose-v4.yml`。
|
||||
|
||||
### 升级执行器
|
||||
|
||||
新增 `systemUpgradeExecutor`,职责明确分为可测试的小函数:
|
||||
|
||||
1. `checkSystemUpgradeCapability()` 检查 Docker CLI、Docker socket、部署目录、`.env`、`docker-compose.yml`。
|
||||
2. `selectComposeAsset(currentCompose []byte) string` 选择 v4/v6 compose 资产。
|
||||
3. `updateEnvVersion(path, version string) error` 原子更新 `.env` 中的 `FLUX_VERSION`。
|
||||
4. `downloadCompose(version, assetName, dest string) error` 下载新版 compose 模板到临时文件。
|
||||
5. `currentBackendImage(containerName string) (string, error)` 获取当前 backend 容器镜像 ID。
|
||||
6. `startSystemUpgradeHelper(version string) error` 启动独立 helper 容器执行固定升级流程。
|
||||
|
||||
升级流程:
|
||||
|
||||
1. 获取全局升级锁,拒绝并发升级。
|
||||
2. 校验目标版本存在且不是 draft。
|
||||
3. 检查升级能力。
|
||||
4. 备份 `.env` 为 `.env.upgrade.bak`,备份 `docker-compose.yml` 为 `docker-compose.yml.upgrade.bak`。
|
||||
5. 下载目标版本的 compose 文件到部署目录临时文件。
|
||||
6. 原子替换 `docker-compose.yml`。
|
||||
7. 原子更新 `.env` 的 `FLUX_VERSION`。
|
||||
8. 通过 Docker socket 查询当前 backend 容器的镜像 ID。
|
||||
9. 使用当前 backend 镜像启动一个不属于 Compose 项目的临时 helper 容器。
|
||||
10. helper 通过 `--volumes-from flux-panel-backend` 继承部署目录挂载,并显式挂载 `/var/run/docker.sock`。
|
||||
11. helper 在 `PANEL_DEPLOY_DIR` 下执行 `docker compose pull backend frontend`。
|
||||
12. helper 等待 5 秒,让 SQLite WAL 等文件刷盘。
|
||||
13. helper 执行 `docker compose up -d backend frontend`,由 Compose 重建前端和后端。
|
||||
14. 后端接口在 helper 成功启动后立即返回;浏览器随后会经历短暂断线。
|
||||
|
||||
PostgreSQL 模式不主动 pull 或重建 `postgres` 服务,避免无关数据库变动。新版 compose 文件仍保留 postgres 配置供后续手动迁移或重建使用。
|
||||
|
||||
### 命令安全
|
||||
|
||||
后端不暴露通用命令执行能力。后端只直接执行 Docker CLI 的固定参数,用于获取当前镜像和启动 helper:
|
||||
|
||||
```go
|
||||
exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", backendContainer)
|
||||
exec.CommandContext(ctx, "docker", "run", "-d", "--rm", "--name", helperName,
|
||||
"--volumes-from", backendContainer,
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
||||
"--entrypoint", "/bin/sh", imageID,
|
||||
"-c", helperScript)
|
||||
```
|
||||
|
||||
`helperScript` 由后端固定生成,不拼接用户输入:
|
||||
|
||||
```sh
|
||||
cd "$PANEL_DEPLOY_DIR" && docker compose pull backend frontend && sleep 5 && docker compose up -d backend frontend
|
||||
```
|
||||
|
||||
工作目录固定为 `PANEL_DEPLOY_DIR`。`PANEL_DEPLOY_DIR` 必须是绝对路径,且必须包含 `.env` 和 `docker-compose.yml`。接口输入只允许影响 `channel` 和已验证的 Release `version`。
|
||||
|
||||
### 超时和错误处理
|
||||
|
||||
1. Release 查询超时沿用现有 GitHub API 客户端超时。
|
||||
2. 下载 compose 文件使用 60 秒超时。
|
||||
3. 启动 helper 使用 30 秒超时,helper 内部命令不受原 HTTP 请求生命周期影响。
|
||||
4. 任一步失败时返回错误信息,并尽量保留 `.upgrade.bak` 供人工恢复。
|
||||
5. 如果 `.env` 更新后后续步骤失败,不自动回滚镜像或容器,避免误判导致更大破坏;错误信息提示备份文件位置。
|
||||
|
||||
## 部署模板设计
|
||||
|
||||
`docker-compose-v4.yml` 和 `docker-compose-v6.yml` 的 backend 服务增加:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/opt/flvx-panel
|
||||
```
|
||||
|
||||
`go-backend/Dockerfile` 的 runtime 镜像通过多阶段构建从官方 `docker:27-cli` 镜像复制 Docker CLI 和 compose 插件到 Debian runtime 镜像,避免依赖 Debian apt 源中的 Docker 包可用性:
|
||||
|
||||
```dockerfile
|
||||
FROM docker:27-cli AS dockercli
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
|
||||
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
|
||||
```
|
||||
|
||||
实现时保留现有 Go builder 和 `/app/paneld` 入口,仅增加 Docker CLI stage 和复制步骤。
|
||||
|
||||
helper 容器使用当前 backend 容器的镜像 ID 启动,而不是额外依赖 `docker:cli` 镜像。这样不引入新的镜像仓库依赖,并保证 helper 内可用的 Docker CLI 与当前后端一致。
|
||||
|
||||
## 前端设计
|
||||
|
||||
在 `vite-frontend/src/pages/config.tsx` 的基本设置或数据库占用附近增加“面板升级”卡片,避免隐藏在节点页导致误解为“节点升级”。
|
||||
|
||||
展示内容:
|
||||
|
||||
1. 当前版本。
|
||||
2. 最新版本。
|
||||
3. 更新通道选择,复用现有 `stable` / `dev` 语义和 `UpdateReleaseChannel` 本地存储。
|
||||
4. 升级能力状态:可用、不可用原因、Docker socket 高权限提示。
|
||||
5. 操作按钮:检查更新、立即升级。
|
||||
|
||||
交互:
|
||||
|
||||
1. 页面加载时调用 `/system/version`。
|
||||
2. 点击“检查更新”调用 `/system/check-updates`。
|
||||
3. 点击“立即升级”前弹出确认框,明确提示服务会短暂中断,并提示 Docker socket 具备宿主高权限。
|
||||
4. 升级请求只等待 helper 启动,超时设置为 60 秒。
|
||||
5. 成功后 toast 显示“升级已触发,面板将在数十秒内重启”,并可提示用户稍后刷新。
|
||||
|
||||
## 安全边界
|
||||
|
||||
Docker socket 挂载等同于给后端容器宿主机级别控制能力。这是本设计的主要风险。缓解措施:
|
||||
|
||||
1. 仅 `/api/v1/system/*` 管理员接口可触发。
|
||||
2. 不提供任意命令执行接口。
|
||||
3. 不允许用户传入下载 URL。
|
||||
4. 不允许用户传入 compose 路径。
|
||||
5. 只升级本仓库 GitHub Release,且跳过 draft。
|
||||
6. 前端明确展示 Docker socket 权限提示。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### Go 单测
|
||||
|
||||
新增 `system_upgrade_test.go` 覆盖:
|
||||
|
||||
1. `selectComposeAsset` 对 v4/v6 compose 内容的判断。
|
||||
2. `.env` 中已有 `FLUX_VERSION` 时更新值。
|
||||
3. `.env` 中缺少 `FLUX_VERSION` 时追加值。
|
||||
4. 缺少部署目录、`.env`、`docker-compose.yml`、Docker socket 时返回不可用原因。
|
||||
5. helper 命令构造固定命令序列,不拼接用户输入。
|
||||
6. 并发升级锁会拒绝第二个升级请求。
|
||||
|
||||
### 手动/集成验证
|
||||
|
||||
1. `go-backend`: `go test ./...`
|
||||
2. `vite-frontend`: `pnpm run build`
|
||||
3. 本地容器验证:启动 Compose 后检查设置页升级卡片可显示能力状态。
|
||||
4. 在无 Docker socket 的开发环境验证按钮禁用并显示原因。
|
||||
|
||||
## 回滚与恢复
|
||||
|
||||
自动升级失败时不做自动容器回滚。后端会保留:
|
||||
|
||||
1. `.env.upgrade.bak`
|
||||
2. `docker-compose.yml.upgrade.bak`
|
||||
|
||||
人工恢复步骤由错误信息提示:进入部署目录,按需恢复备份文件,再执行 `docker compose up -d backend frontend`。
|
||||
|
||||
## 决策记录
|
||||
|
||||
本设计已确定采用 Docker socket 整套升级方案,不再保留二进制替换作为本次实现路径。Docker socket 的权限风险通过管理员限制、命令白名单和前端提示控制。
|
||||
@@ -0,0 +1,377 @@
|
||||
# FLVX 安全问题修复设计
|
||||
|
||||
**日期**: 2026-05-13
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
针对 PR #502 提到的安全问题,对 FLVX 后端认证、配置访问控制、配置写入保护、备份导出和 JWT 失效模型做一次集中修复。目标是优先消除高风险漏洞,同时保留当前必须兼容的登录页品牌配置读取和验证码兼容行为。
|
||||
|
||||
本设计采用“高危项一次收口,结构性问题只分析不重构”的策略:本轮修复 MD5 密码存储、未受控配置读取、敏感配置写入、备份配置泄露和 JWT 长期有效且改密后不失效的问题;不修改“无 Cloudflare secret 时允许当前 captcha 兼容行为”,也不重构 `autoMigrateAll()` 与 `migrateSchema()` 的双迁移入口。
|
||||
|
||||
## 背景
|
||||
|
||||
当前主线存在以下已确认问题:
|
||||
|
||||
1. `login`、`open_api/sub_store`、用户改密、管理员创建用户和管理员修改用户密码仍然使用 `security.MD5(...)`。
|
||||
2. `/api/v1/config/get` 在 middleware 的 `shouldSkip()` 中被匿名放行,导致任意调用方可以读取绝大多数配置。
|
||||
3. `updateConfigs()` 与 `updateSingleConfig()` 使用了两套不同的限制逻辑,敏感配置键在单项写接口中未被保护。
|
||||
4. `ExportAll()` 和 `ExportPartial(types=["configs"])` 会直接导出所有配置,包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。
|
||||
5. JWT 当前有效期为 90 天,且 token 在用户改密、禁用、角色变化后仍可继续使用到过期。
|
||||
|
||||
同时存在两个重要约束:
|
||||
|
||||
1. 登录页和未登录态品牌展示依赖匿名读取 `app_name`、`app_logo`、`app_favicon`、`app_bg_image` 和 `cloudflare_site_key`。
|
||||
2. `tests/contract/migration_contract_test.go` 已把“无 Cloudflare secret 时允许当前 captcha 兼容行为”定义为既有契约,本轮不改变。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 新增和更新后的用户密码不再以 MD5 存储。
|
||||
2. 历史 MD5 用户可在首次成功认证时自动迁移到强哈希。
|
||||
3. 匿名请求不能再读取任意配置,只能读取明确的公开配置白名单。
|
||||
4. 通用配置写接口不能覆盖敏感配置键。
|
||||
5. 备份导出默认不泄露敏感配置明文。
|
||||
6. 用户改密、禁用或角色变化后,旧 JWT 应立即失效。
|
||||
7. 不破坏现有登录页品牌展示和 captcha 兼容行为。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不重构 `open_api/sub_store` 的整体认证模型;该接口仍使用现有用户名和密码查询参数语义。
|
||||
2. 不实现完整 refresh token、session 管理后台或 token 黑名单体系。
|
||||
3. 不改变“无 Cloudflare secret 时允许当前 captcha 兼容行为”。
|
||||
4. 不在本轮重构 `autoMigrateAll()` 与 `migrateSchema()` 的启动流程。
|
||||
5. 不引入前端测试框架。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
|
||||
- `go-backend/internal/security/`
|
||||
- `go-backend/internal/auth/jwt.go`
|
||||
- `go-backend/internal/http/middleware/auth.go`
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `go-backend/internal/store/model/model.go`
|
||||
- `go-backend/internal/store/repo/repository.go`
|
||||
- `go-backend/internal/store/repo/repository_mutations.go`
|
||||
- `go-backend/tests/contract/`
|
||||
- `go-backend/internal/store/repo/*_test.go`
|
||||
|
||||
### 前端
|
||||
|
||||
- `vite-frontend/src/api/index.ts`
|
||||
- `vite-frontend/src/config/site.ts`
|
||||
- `vite-frontend/src/pages/index.tsx`
|
||||
- 任何在未登录态读取品牌配置的组件
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 已确认决策
|
||||
|
||||
1. 本轮采用安全优先策略,允许收紧危险默认行为。
|
||||
2. MD5 密码采用“登录成功时自动迁移”的兼容方案。
|
||||
3. captcha 在未配置 Cloudflare secret 时的兼容行为保持不变。
|
||||
4. JWT 采用“最小可撤销”方案,而不是完整 session 体系。
|
||||
5. 双重迁移系统只分析,不在本轮中修改。
|
||||
|
||||
### 迁移系统分析结论
|
||||
|
||||
`autoMigrateAll()` 与 `migrateSchema()` 当前职责并不相同:
|
||||
|
||||
1. `autoMigrateAll()` 负责表和列结构补齐。
|
||||
2. `migrateSchema()` 负责基于 `schema_version` 的数据修正,以及 PostgreSQL ID 默认值修复等兼容迁移。
|
||||
3. 现有 `repository_migrate_test.go` 已明确覆盖这两部分逻辑,说明它们在现有代码库中是被依赖的互补结构,而不是已确认的重复安全漏洞。
|
||||
|
||||
因此本轮仅记录该分析结论,不把双迁移入口纳入改动范围,避免把安全修复扩展为启动流程重构。
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 1. 密码存储与认证迁移
|
||||
|
||||
在 `internal/security/` 中新增统一密码能力,替代各处直接使用 `security.MD5(...)` 的做法。
|
||||
|
||||
建议新增以下接口:
|
||||
|
||||
```go
|
||||
func HashPassword(plain string) (string, error)
|
||||
func VerifyPassword(storedHash, plain string) (ok bool, legacy bool)
|
||||
func IsLegacyPasswordHash(storedHash string) bool
|
||||
```
|
||||
|
||||
哈希算法使用 `bcrypt`:
|
||||
|
||||
1. `user.pwd` 当前为 `varchar(100)`,足以容纳 bcrypt 哈希。
|
||||
2. 不需要修改密码列长度,改动最小。
|
||||
3. 对当前 Go 后端来说,bcrypt 是最稳妥的强哈希升级路径。
|
||||
|
||||
所有密码入口统一改为走这套能力:
|
||||
|
||||
1. `login`
|
||||
2. `openAPISubStore`
|
||||
3. `updatePassword`
|
||||
4. `userCreate`
|
||||
5. `userUpdate` 中的管理员改密路径
|
||||
|
||||
认证迁移规则:
|
||||
|
||||
1. 如果数据库中存的是 bcrypt,则按 bcrypt 校验。
|
||||
2. 如果数据库中存的是历史 MD5,则先按旧逻辑校验。
|
||||
3. 历史 MD5 校验成功后,立即把 `pwd` 改写为 bcrypt。
|
||||
4. 自动迁移不仅在网页登录时执行,也在 `open_api/sub_store` 成功鉴权时执行,避免只使用订阅接口的老用户永远停留在 MD5。
|
||||
|
||||
默认管理员种子账号仍保留当前默认密码语义和 `requirePasswordChange` 行为,但种子哈希改为 bcrypt,不再在新建数据库中写入 MD5 值。
|
||||
|
||||
### 2. JWT 最小可撤销方案
|
||||
|
||||
本轮不引入 refresh token 和黑名单表,而是做一个可以立即生效的最小撤销闭环。
|
||||
|
||||
#### 数据模型
|
||||
|
||||
在 `user` 表新增字段:
|
||||
|
||||
- `password_changed_at BIGINT NOT NULL DEFAULT 0`
|
||||
|
||||
该字段专门表示密码最后一次变更时间,不能复用现有 `updated_time`,原因是 `updated_time` 还会被流量、状态或其他用户资料更新触发,复用后会让非密码更新错误地使 token 失效。
|
||||
|
||||
#### token 签发与校验
|
||||
|
||||
继续使用现有 `iat` 声明,但把有效期从 90 天收紧到 7 天。
|
||||
|
||||
token 校验分两步:
|
||||
|
||||
1. 先做现有签名和过期时间校验。
|
||||
2. 再读取用户最小认证状态,确认:
|
||||
- 用户仍存在
|
||||
- 用户状态未被禁用
|
||||
- 当前 `role_id` 与 token 中一致
|
||||
- `claims.iat` 不早于 `password_changed_at`
|
||||
|
||||
为避免 middleware 每次都查询完整用户对象,Repository 新增专用读取方法,只返回 token 校验需要的最小字段,例如:
|
||||
|
||||
```go
|
||||
type UserAuthState struct {
|
||||
ID int64
|
||||
RoleID int
|
||||
Status int
|
||||
PasswordChangedAt int64
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserAuthState(userID int64) (*UserAuthState, error)
|
||||
```
|
||||
|
||||
#### 失效语义
|
||||
|
||||
以下场景下,旧 token 应立即失效:
|
||||
|
||||
1. 用户修改密码
|
||||
2. 管理员修改用户密码
|
||||
3. 用户被禁用
|
||||
4. 用户角色发生变化
|
||||
|
||||
这会带来一次明确的兼容收紧:升级完成后,部分历史 token 可能因为寿命策略或认证状态变化而失效,这是安全优先下的可接受行为。
|
||||
|
||||
### 3. 配置读取访问控制
|
||||
|
||||
为了避免继续让 `/api/v1/config/get` 承担“有时匿名、有时鉴权”的混合语义,本设计将公开配置读取拆成单独的 public 端点。
|
||||
|
||||
#### 端点设计
|
||||
|
||||
保留现有受保护端点:
|
||||
|
||||
- `POST /api/v1/config/get`
|
||||
|
||||
新增公开端点:
|
||||
|
||||
- `POST /api/v1/public/config/get`
|
||||
|
||||
middleware 仅对白名单 public 端点放行,不再放行 `/api/v1/config/get`。
|
||||
|
||||
#### 公开白名单
|
||||
|
||||
匿名仅允许读取以下配置:
|
||||
|
||||
1. `app_name`
|
||||
2. `app_logo`
|
||||
3. `app_favicon`
|
||||
4. `app_bg_image`
|
||||
5. `cloudflare_site_key`
|
||||
|
||||
理由:
|
||||
|
||||
1. 登录页与未登录态品牌渲染依赖前四项。
|
||||
2. 登录页在 captcha 开启时需要读取 `cloudflare_site_key`。
|
||||
3. 其他配置不应暴露给匿名方。
|
||||
|
||||
前端调整规则:
|
||||
|
||||
1. 登录页和 `site.ts` 中的未登录态品牌配置读取改走 `/public/config/get`。
|
||||
2. 登录后页面仍使用现有 `/config/get` 或 `/config/list`。
|
||||
3. 已登录页面中的配置读取逻辑不变,只是恢复为真正受 JWT 保护。
|
||||
|
||||
### 4. 配置写保护统一
|
||||
|
||||
当前 `updateConfigs()` 与 `updateSingleConfig()` 各自维护不同限制逻辑,是本次越权写入漏洞的根源。本轮把配置访问规则统一收口为一套辅助函数。
|
||||
|
||||
建议新增配置策略定义:
|
||||
|
||||
```go
|
||||
type ConfigAccessPolicy struct {
|
||||
PublicReadable bool
|
||||
Sensitive bool
|
||||
CommercialOnly bool
|
||||
}
|
||||
```
|
||||
|
||||
由统一函数返回某个 key 的策略,再由:
|
||||
|
||||
1. `public config get`
|
||||
2. `config get`
|
||||
3. `config list`
|
||||
4. `updateConfigs()`
|
||||
5. `updateSingleConfig()`
|
||||
|
||||
共同复用。
|
||||
|
||||
敏感配置键至少包含:
|
||||
|
||||
1. `jwt_secret`
|
||||
2. `license_key`
|
||||
3. `cloudflare_secret_key`
|
||||
|
||||
这些键的写入规则:
|
||||
|
||||
1. 不允许通过通用配置写接口改写。
|
||||
2. 不允许通过公开读取接口读取。
|
||||
3. 非管理员在配置列表接口中也不能获得。
|
||||
|
||||
商业版白名单键继续沿用现有语义,例如:
|
||||
|
||||
1. `app_name`
|
||||
2. `app_logo`
|
||||
3. `app_favicon`
|
||||
4. `hide_footer_brand`
|
||||
|
||||
但其判断逻辑同样统一走同一套策略函数,避免再次出现单接口漏判。
|
||||
|
||||
### 5. 备份导出与导入脱敏
|
||||
|
||||
备份系统改为“默认安全导出”,而不是“完整明文镜像”。
|
||||
|
||||
#### 导出
|
||||
|
||||
`ExportAll()` 和 `ExportPartial(types=["configs"])` 在写入 `backup.Configs` 前都先经过统一过滤函数,移除敏感配置键。
|
||||
|
||||
敏感配置键与配置写保护列表保持一致:
|
||||
|
||||
1. `jwt_secret`
|
||||
2. `license_key`
|
||||
3. `cloudflare_secret_key`
|
||||
|
||||
#### 导入
|
||||
|
||||
导入配置时,即使旧备份中带有上述敏感键,也会在导入前被丢弃,不允许通过备份恢复路径覆盖在线安全配置。
|
||||
|
||||
该设计的取舍如下:
|
||||
|
||||
1. 保留大部分业务配置、节点、转发、用户数据的恢复能力。
|
||||
2. 不再把备份文件当作核心密钥分发载体。
|
||||
3. `UserBackup.Pwd` 仍然保留,以维持用户恢复语义;在本轮密码升级后,这些值将是 bcrypt 哈希,而不是 MD5。
|
||||
|
||||
### 6. captcha 兼容行为
|
||||
|
||||
`captcha_enabled`、`cloudflare_site_key`、`cloudflare_secret_key` 的现有兼容行为保持不变。
|
||||
|
||||
明确保持以下现状:
|
||||
|
||||
1. 当未完整配置 Cloudflare key 时,当前 contract test 约定的兼容路径继续存在。
|
||||
2. 本轮不把 captcha 兼容逻辑从“兼容旧行为”切换为“严格校验”。
|
||||
|
||||
这样可以避免把一轮安全修复扩展成登录流程行为变更,同时与用户已确认的范围保持一致。
|
||||
|
||||
## 错误处理与兼容行为
|
||||
|
||||
### 错误处理
|
||||
|
||||
保持现有 API envelope:`{code, msg, data, ts}`。
|
||||
|
||||
建议的接口行为:
|
||||
|
||||
1. `POST /api/v1/public/config/get` 请求非公开 key 时返回 `403`。
|
||||
2. 受保护配置端点未登录时返回 `401`。
|
||||
3. 登录、订阅接口、改密接口继续返回通用认证失败,不暴露“用户名存在但密码错误”等细节。
|
||||
4. token 因签名错误、过期、改密、禁用或角色变化失效时,统一返回现有 `401` 语义。
|
||||
5. 备份导入中出现敏感配置键时,接口整体仍允许成功导入其他数据,敏感键静默忽略。
|
||||
|
||||
### 保留兼容
|
||||
|
||||
1. 登录页和未登录态品牌展示继续可用。
|
||||
2. 未配置 Cloudflare secret 时的 captcha 兼容逻辑继续保留。
|
||||
3. 历史 MD5 用户仍可继续认证,并在成功后自动迁移。
|
||||
|
||||
### 刻意收紧
|
||||
|
||||
1. 匿名方不再可读取任意配置。
|
||||
2. 通用配置写接口不再能写入敏感键。
|
||||
3. 备份不再导出敏感配置明文。
|
||||
4. 改密、禁用和角色变化会立即使旧 token 失效。
|
||||
|
||||
## 测试设计
|
||||
|
||||
本轮以 Go 单测和 contract test 为主,覆盖以下场景。
|
||||
|
||||
### 密码迁移
|
||||
|
||||
1. 历史 MD5 用户在网页登录成功后,数据库中的 `pwd` 被升级为 bcrypt。
|
||||
2. 历史 MD5 用户在 `open_api/sub_store` 成功鉴权后,同样触发迁移。
|
||||
3. 新建用户后落库的是 bcrypt,而不是 MD5。
|
||||
4. 管理员修改用户密码和用户自助改密后,落库的是 bcrypt。
|
||||
|
||||
### JWT 最小可撤销
|
||||
|
||||
1. 正常 token 仍可访问受保护接口。
|
||||
2. 改密后旧 token 失效。
|
||||
3. 用户被禁用后旧 token 失效。
|
||||
4. 用户角色变化后旧 token 失效。
|
||||
5. 过期 token 失效。
|
||||
|
||||
### 配置访问控制
|
||||
|
||||
1. 匿名访问公开配置成功。
|
||||
2. 匿名访问非公开配置失败。
|
||||
3. 已登录页面需要的普通配置读取仍然可用。
|
||||
4. `updateSingleConfig()` 无法修改敏感键。
|
||||
5. `updateConfigs()` 同样无法修改敏感键。
|
||||
|
||||
### 备份脱敏
|
||||
|
||||
1. `ExportAll()` 不包含敏感配置。
|
||||
2. `ExportPartial(types=["configs"])` 不包含敏感配置。
|
||||
3. 导入带敏感键的备份时,这些键不会被写回数据库。
|
||||
4. 非敏感配置和其他业务数据仍可正常导入导出。
|
||||
|
||||
### 迁移系统回归
|
||||
|
||||
1. 现有 `repository_migrate_test.go` 保持通过。
|
||||
2. 本轮不对 `autoMigrateAll()` 与 `migrateSchema()` 的职责边界做行为性改动。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 数据库中不再新增 MD5 密码。
|
||||
2. 历史 MD5 用户可在首次成功认证后自动升级到 bcrypt。
|
||||
3. 匿名调用方不能再读取非公开配置。
|
||||
4. `updateSingleConfig()` 和 `updateConfigs()` 都无法改写敏感配置键。
|
||||
5. 备份导出默认不包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。
|
||||
6. 改密、禁用和角色变化后,旧 JWT 立即失效。
|
||||
7. 登录页品牌展示和 captcha 兼容行为不被破坏。
|
||||
8. 现有迁移测试和本轮新增安全测试全部通过。
|
||||
|
||||
## PR #502 处置
|
||||
|
||||
PR #502 的价值在于指出了真实问题,但其实现方式只是把讽刺性注释写进生产代码,并未修复漏洞。因此该 PR 不应合并。
|
||||
|
||||
执行阶段的处置方式:
|
||||
|
||||
1. 关闭 PR #502。
|
||||
2. 在关闭说明中指出:问题成立,但修复将通过正式代码与测试提交完成,而不是通过向源文件加入讽刺性注释。
|
||||
3. 后续在新提交中按本设计逐项修复。
|
||||
@@ -0,0 +1,231 @@
|
||||
# FLVX Dependabot 告警修复设计
|
||||
|
||||
**日期**: 2026-05-14
|
||||
**状态**: 待审核
|
||||
**范围**: 仅处理 GitHub Dependabot 依赖告警
|
||||
|
||||
## 概述
|
||||
|
||||
本设计针对 `Sagit-chu/flvx` 当前 open Dependabot alerts 制定依赖修复方案。目标是在不混入业务安全逻辑改造的前提下,消除或最大限度降低依赖漏洞告警,并通过各模块现有构建和测试命令验证兼容性。
|
||||
|
||||
当前告警共 21 条:
|
||||
|
||||
- Critical: 1
|
||||
- High: 6
|
||||
- Medium: 13
|
||||
- Low: 1
|
||||
|
||||
按生态划分:
|
||||
|
||||
- Go: 14
|
||||
- npm: 7
|
||||
|
||||
Go 告警中有一部分因为 `go-gost/go.mod` 和 `go-gost/x/go.mod` 同时被扫描而重复出现;实际修复应按依赖和模块关系聚合处理,而不是按 alert 数逐条机械修改。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 修复 `go-backend` 中 `github.com/jackc/pgx/v5` 的 critical 和 low 告警。
|
||||
2. 修复 `vite-frontend` 中 npm 直接依赖、开发依赖和 lockfile 传递依赖告警。
|
||||
3. 修复 `go-gost` 与 `go-gost/x` 中可升级到 patched version 的 Go 依赖告警。
|
||||
4. 对 Dependabot 未给出 patched version 的依赖进行单独确认,避免盲目大版本升级。
|
||||
5. 保持改动最小化,便于回滚和定位 CI 失败。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不处理既有认证、配置读取、备份导出、JWT 失效等业务安全逻辑问题。
|
||||
2. 不合并或修改 `2026-05-13-security-remediation` 相关设计和计划。
|
||||
3. 不进行 `go get -u ./...` 或 `pnpm update` 级别的大范围依赖升级。
|
||||
4. 不引入前端测试框架。
|
||||
5. 不编辑 `install.sh`、`panel_install.sh` 或 generated `.pb.go` 文件。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### Backend
|
||||
|
||||
- `go-backend/go.mod`
|
||||
- `go-backend/go.sum`
|
||||
|
||||
### Frontend
|
||||
|
||||
- `vite-frontend/package.json`
|
||||
- `vite-frontend/pnpm-lock.yaml`
|
||||
|
||||
### Agent
|
||||
|
||||
- `go-gost/go.mod`
|
||||
- `go-gost/go.sum`
|
||||
- `go-gost/x/go.mod`
|
||||
- `go-gost/x/go.sum`
|
||||
|
||||
`go-gost/go.mod` 使用:
|
||||
|
||||
```go
|
||||
replace github.com/go-gost/x => ./x
|
||||
```
|
||||
|
||||
因此 `go-gost/x` 的依赖修复应先完成,再验证 `go-gost` 主模块。
|
||||
|
||||
## 修复策略
|
||||
|
||||
采用“分模块、最小安全升级”策略。
|
||||
|
||||
### 1. go-backend
|
||||
|
||||
Dependabot alerts:
|
||||
|
||||
- `github.com/jackc/pgx/v5 < 5.9.0`
|
||||
- severity: critical
|
||||
- summary: memory-safety vulnerability
|
||||
- `github.com/jackc/pgx/v5 < 5.9.2`
|
||||
- severity: low
|
||||
- summary: SQL injection via placeholder confusion with dollar quoted string literals
|
||||
|
||||
当前版本:
|
||||
|
||||
- `github.com/jackc/pgx/v5 v5.7.3`
|
||||
|
||||
目标版本:
|
||||
|
||||
- `github.com/jackc/pgx/v5 v5.9.2`
|
||||
|
||||
设计说明:
|
||||
|
||||
- 直接升到 `v5.9.2`,同时覆盖 `v5.9.0` 和 `v5.9.2` 的修复要求。
|
||||
- 不调整 GORM PostgreSQL driver,除非 `go mod tidy` 或测试显示必须联动升级。
|
||||
- 验证以 backend 全量测试为准。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
```
|
||||
|
||||
### 2. vite-frontend
|
||||
|
||||
Dependabot alerts:
|
||||
|
||||
- `postcss < 8.5.10`
|
||||
- appears in `vite-frontend/package.json`
|
||||
- appears in `vite-frontend/pnpm-lock.yaml`
|
||||
- `serialize-javascript <= 7.0.2` and `< 7.0.5`
|
||||
- lockfile includes `serialize-javascript@6.0.2`
|
||||
- package override currently pins `serialize-javascript` to `7.0.3`
|
||||
- `fast-uri <= 3.1.1`
|
||||
- lockfile currently includes `fast-uri@3.1.0`
|
||||
- `@babel/plugin-transform-modules-systemjs <= 7.29.3`
|
||||
- lockfile currently includes `7.29.0`
|
||||
|
||||
目标版本:
|
||||
|
||||
- `postcss >= 8.5.10`
|
||||
- `serialize-javascript >= 7.0.5`
|
||||
- `fast-uri >= 3.1.2`
|
||||
- `@babel/plugin-transform-modules-systemjs >= 7.29.4`
|
||||
|
||||
设计说明:
|
||||
|
||||
- 对直接声明的 `postcss` 更新 `package.json`。
|
||||
- 将 `overrides.serialize-javascript` 从 `7.0.3` 更新到 `7.0.5`。
|
||||
- 对只出现在 lockfile 的传递依赖,优先通过 `pnpm install` 重新解析 lockfile,让上游范围自然选择 patched version。
|
||||
- 如果 lockfile 仍保留 vulnerable 版本,再添加精确 `pnpm.overrides` 或现有 `overrides` 条目,避免无关依赖大升级。
|
||||
- 不引入前端测试框架,验证使用现有 build。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
可选补充检查:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm why postcss serialize-javascript fast-uri @babel/plugin-transform-modules-systemjs)
|
||||
```
|
||||
|
||||
### 3. go-gost/x
|
||||
|
||||
Dependabot alerts:
|
||||
|
||||
- `github.com/sirupsen/logrus < 1.8.3`
|
||||
- severity: high
|
||||
- current: `v1.8.1`
|
||||
- target: at least `v1.8.3`
|
||||
- `github.com/quic-go/quic-go < 0.57.0`
|
||||
- severity: medium
|
||||
- current: `v0.49.1`
|
||||
- target: `v0.57.0`
|
||||
- `github.com/quic-go/webtransport-go <= 0.9.0`
|
||||
- severity: medium
|
||||
- current: `v0.8.1-0.20241018022711-4ac2c9250e66`
|
||||
- target: `v0.10.0`
|
||||
- `github.com/pion/dtls/v2 <= 2.2.12`
|
||||
- severity: medium
|
||||
- current: `v2.2.6`
|
||||
- target: no patched version provided by Dependabot
|
||||
|
||||
设计说明:
|
||||
|
||||
- 先处理 `go-gost/x`,因为它是 `go-gost` 通过 `replace` 使用的本地模块。
|
||||
- 将 `logrus`、`quic-go` 和 `webtransport-go` 升级到 Dependabot 标出的 patched version。
|
||||
- 单独处理 `pion/dtls/v2`,因为 Dependabot 没有提供 `first_patched_version`。
|
||||
- 对 `pion/dtls/v2`,先查询可用 module versions 和 advisory 详情。如果存在 patched `v2` release,使用最小安全修复版本;如果不存在 patched version,则记录残留告警,避免在没有兼容性评估的情况下强行做高风险大版本迁移。
|
||||
- 升级完成后,在 `go-gost/x` 中运行 `go mod tidy` 并编译/测试该模块。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
### 4. go-gost
|
||||
|
||||
Dependabot reports the same vulnerable Go dependencies in `go-gost/go.mod`.
|
||||
|
||||
设计说明:
|
||||
|
||||
- `go-gost/x` 修复后,再更新 `go-gost` 的 module requirements,使主模块也解析到 patched versions。
|
||||
- 保留 `replace github.com/go-gost/x => ./x`。
|
||||
- 使用针对具体漏洞依赖的 `go get` 命令,不使用宽泛的 `go get -u`。
|
||||
- 定向升级后运行 `go mod tidy`。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go test ./...)
|
||||
(cd go-gost && go build .)
|
||||
```
|
||||
|
||||
## 处理顺序
|
||||
|
||||
1. 修复 `go-backend` 的 `pgx/v5`。
|
||||
2. 修复 `vite-frontend` 的 npm dependencies 和 lockfile。
|
||||
3. 修复 `go-gost/x` 的 Go dependencies。
|
||||
4. 同步并验证 `go-gost`。
|
||||
5. 再次查询 Dependabot alerts,确认 alert 数量下降,或记录有意保留的未解决告警。
|
||||
|
||||
这个顺序可以降低耦合:backend 和 frontend 能独立验证,而 `go-gost/x` 因本地 module replacement 必须先于 `go-gost` 处理。
|
||||
|
||||
## 错误处理与回退
|
||||
|
||||
如果定向依赖升级无法解析:
|
||||
|
||||
1. 使用 `go mod why`、`go mod graph` 或 `pnpm why` 检查依赖链。
|
||||
2. 优先添加最小显式 requirement 或 override,以强制解析到 patched version。
|
||||
3. 除非定向解析不可行,否则避免宽泛升级。
|
||||
4. 如果 patched version 不可用,记录准确 advisory、受影响依赖、当前暴露面,以及保留 open 状态的原因。
|
||||
|
||||
如果验证失败:
|
||||
|
||||
1. 将失败范围限制在当前升级的模块内。
|
||||
2. 先分析编译错误或测试失败,再决定是否调整版本。
|
||||
3. 优先选择能通过测试和构建的最低 patched version。
|
||||
4. 不通过删除测试或修改无关应用代码来掩盖失败。
|
||||
|
||||
## 成功标准
|
||||
|
||||
1. `pgx/v5` 升级后,`go-backend` 测试通过。
|
||||
2. npm dependency 和 lockfile 更新后,frontend production build 通过。
|
||||
3. 定向升级后,`go-gost/x` 测试通过。
|
||||
4. 同步 module requirements 后,`go-gost` 测试和构建通过。
|
||||
5. 最终 Dependabot API 查询显示所有可修复告警已关闭或数量明确下降。
|
||||
6. 任何剩余告警都有明确记录;尤其是 `github.com/pion/dtls/v2` 如果不存在 patched version,需要记录原因和下一步动作。
|
||||
@@ -9,10 +9,14 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
||||
|
||||
FROM docker:27-cli AS dockercli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /out/paneld /app/paneld
|
||||
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
|
||||
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
ENV SERVER_ADDR=:6365
|
||||
EXPOSE 6365
|
||||
|
||||
+2
-2
@@ -6,7 +6,8 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.3
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -22,7 +23,6 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
|
||||
+6
-6
@@ -17,8 +17,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
|
||||
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -36,10 +36,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
|
||||
const (
|
||||
algorithm = "HmacSHA256"
|
||||
expireTime = 90 * 24 * time.Hour
|
||||
expireTime = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Iat int64 `json:"iat"`
|
||||
IatMs int64 `json:"iat_ms"`
|
||||
Exp int64 `json:"exp"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
@@ -30,11 +31,15 @@ type tokenHeader struct {
|
||||
}
|
||||
|
||||
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
|
||||
now := time.Now()
|
||||
return GenerateTokenAt(userID, username, roleID, secret, time.Now())
|
||||
}
|
||||
|
||||
func GenerateTokenAt(userID int64, username string, roleID int, secret string, now time.Time) (string, error) {
|
||||
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
|
||||
claims := Claims{
|
||||
Sub: strconv.FormatInt(userID, 10),
|
||||
Iat: now.Unix(),
|
||||
IatMs: now.UnixMilli(),
|
||||
Exp: now.Add(expireTime).Unix(),
|
||||
User: username,
|
||||
Name: username,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth
|
||||
|
||||
type UserAuthState struct {
|
||||
ID int64
|
||||
RoleID int
|
||||
Status int
|
||||
PasswordChangedAt int64
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req nameRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if configName == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if !repo.IsPublicConfigKey(configName) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(configName)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(cfg))
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "app_name", "FLVX Brand")
|
||||
seedConfigValue(t, r, "app_logo", "logo-data")
|
||||
seedConfigValue(t, r, "app_favicon", "favicon-data")
|
||||
seedConfigValue(t, r, "app_bg_image", "bg-data")
|
||||
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
}
|
||||
|
||||
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func TestConfigGetAllowsPublicCloudflareSiteKeyWithoutAuthForCachedLoginPage(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"cloudflare_site_key"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerConfigValue(t, resp, "cloudflare_site_key", "site-key")
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsSensitiveKeysWithoutAuth(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func TestConfigGetAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
seedConfigValue(t, r, "jwt_secret", "jwt-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"jwt_secret":"rotated-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
}
|
||||
|
||||
func TestConfigUpdateSingleAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"jwt_secret","value":"rotated-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsCloudflareSecretKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"cloudflare_secret_key":"turnstile-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "turnstile-secret" {
|
||||
t.Fatalf("expected cloudflare_secret_key to be updated, got %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateSingleAllowsCloudflareSecretKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"cloudflare_secret_key","value":"turnstile-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "turnstile-secret" {
|
||||
t.Fatalf("expected cloudflare_secret_key to be updated, got %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"license_key":"license-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("license_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "license-secret" {
|
||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"license_key","value":"license-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("license_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "license-secret" {
|
||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func setupConfigAccessTestRouter(t *testing.T) (http.Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
r, err := repo.Open(t.TempDir() + "/config-access.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
h := New(r, "unit-test-secret")
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
wrapped := middleware.Recover(mux)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: "unit-test-secret", GetUserAuthState: h.GetUserAuthState})(wrapped)
|
||||
wrapped = middleware.RequestLog(wrapped)
|
||||
wrapped = middleware.CORS(wrapped)
|
||||
return wrapped, r
|
||||
}
|
||||
|
||||
func seedConfigValue(t *testing.T, r *repo.Repository, name, value string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`INSERT INTO vite_config(name, value, time) VALUES(?, ?, 0) ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time`, name, value).Error; err != nil {
|
||||
t.Fatalf("seed config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGenerateConfigAccessToken(t *testing.T, userID int64, username string, roleID int) string {
|
||||
t.Helper()
|
||||
token, err := auth.GenerateToken(userID, username, roleID, "unit-test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func assertHandlerCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expected {
|
||||
t.Fatalf("expected code %d, got %d", expected, out.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHandlerCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expectedCode || out.Msg != expectedMsg {
|
||||
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHandlerConfigValue(t *testing.T, rec *httptest.ResponseRecorder, expectedName, expectedValue string) {
|
||||
t.Helper()
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d", out.Code)
|
||||
}
|
||||
if out.Data.Name != expectedName || out.Data.Value != expectedValue {
|
||||
t.Fatalf("expected config (%q,%q), got (%q,%q)", expectedName, expectedValue, out.Data.Name, out.Data.Value)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type Handler struct {
|
||||
jobsWG sync.WaitGroup
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
systemUpgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
nodeOnlineRedeployAt map[int64]time.Time
|
||||
nodeOnlineRedeployQueued map[int64]struct{}
|
||||
@@ -135,6 +136,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
}
|
||||
h.metrics.RecordNodeMetric(nodeID, metricInfo)
|
||||
})
|
||||
h.wsServer.SetUserAuthStateLookup(h.GetUserAuthState)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -142,6 +144,10 @@ func (h *Handler) WebSocketHandler() http.Handler {
|
||||
return h.wsServer
|
||||
}
|
||||
|
||||
func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
|
||||
return h.repo.GetUserAuthState(userID)
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/login", h.login)
|
||||
mux.HandleFunc("/api/v1/user/list", h.userList)
|
||||
@@ -151,11 +157,15 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
|
||||
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
|
||||
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
|
||||
mux.HandleFunc("/api/v1/public/config/get", h.getPublicConfigByName)
|
||||
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
|
||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||
mux.HandleFunc("/api/v1/system/storage", h.storageSummary)
|
||||
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
|
||||
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
|
||||
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
|
||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
@@ -330,7 +340,8 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||
return
|
||||
}
|
||||
if user.Pwd != security.MD5(req.Password) {
|
||||
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, req.Password)
|
||||
if !passwordMatched {
|
||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||
return
|
||||
}
|
||||
@@ -338,8 +349,20 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
||||
return
|
||||
}
|
||||
issueAt := time.Now()
|
||||
if passwordWasLegacy {
|
||||
updatedAt := time.Now().UnixMilli()
|
||||
hashedPassword, err := security.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
log.Printf("legacy password rehash skipped user_id=%d path=login err=%v", user.ID, err)
|
||||
} else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, updatedAt); err != nil {
|
||||
log.Printf("legacy password rehash update skipped user_id=%d path=login err=%v", user.ID, err)
|
||||
} else {
|
||||
issueAt = time.UnixMilli(updatedAt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(user.ID, user.User, user.RoleID, h.jwtSecret)
|
||||
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, issueAt)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -370,13 +393,17 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
switch configName {
|
||||
case "license_key", "cloudflare_secret_key", "jwt_secret":
|
||||
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(req.Name)
|
||||
if _, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims); !ok && !repo.IsPublicConfigKey(configName) {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(configName)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -553,10 +580,27 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if user == nil || user.Pwd != security.MD5(password) {
|
||||
if user == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
||||
return
|
||||
}
|
||||
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, password)
|
||||
if !passwordMatched {
|
||||
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
||||
return
|
||||
}
|
||||
if user.Status == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
||||
return
|
||||
}
|
||||
if passwordWasLegacy {
|
||||
hashedPassword, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
log.Printf("legacy password rehash skipped user_id=%d path=sub_store err=%v", user.ID, err)
|
||||
} else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("legacy password rehash update skipped user_id=%d path=sub_store err=%v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
const giga = int64(1024 * 1024 * 1024)
|
||||
headerValue := ""
|
||||
@@ -944,6 +988,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if repo.IsSensitiveConfigKey(key) && !isAdminRequest(r) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
if protectedKeys[key] && isCommercial != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
@@ -981,6 +1029,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if repo.IsSensitiveConfigKey(name) && !isAdminRequest(r) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
||||
@@ -1007,6 +1059,14 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func isAdminRequest(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
|
||||
return ok && claims.RoleID == 0
|
||||
}
|
||||
|
||||
func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "app_logo", "app_favicon":
|
||||
@@ -1251,7 +1311,8 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.Pwd != security.MD5(req.CurrentPassword) {
|
||||
passwordMatched, _ := security.VerifyPassword(user.Pwd, req.CurrentPassword)
|
||||
if !passwordMatched {
|
||||
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
||||
return
|
||||
}
|
||||
@@ -1266,7 +1327,12 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, security.MD5(req.NewPassword), time.Now().UnixMilli()); err != nil {
|
||||
hashedPassword, err := security.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, hashedPassword, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,12 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UnixMilli()
|
||||
maxConn := asInt(req["maxConn"], 0)
|
||||
|
||||
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, maxConn, now)
|
||||
hashedPassword, err := security.HashPassword(pwd)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
userID, err := h.repo.CreateUser(username, hashedPassword, roleID, expTime, flow, flowResetTime, num, status, maxConn, now)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -176,7 +181,12 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
|
||||
hashedPassword, err := security.HashPassword(pwd)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpdateUserWithPassword(id, username, hashedPassword, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const (
|
||||
panelDeployDirEnv = "PANEL_DEPLOY_DIR"
|
||||
panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER"
|
||||
defaultPanelDeployDir = "/opt/flvx-panel"
|
||||
defaultPanelBackendName = "flux-panel-backend"
|
||||
dockerSocketPath = "/var/run/docker.sock"
|
||||
maxSystemUpgradeComposeAssetBytes = 1 << 20
|
||||
systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启"
|
||||
systemUpgradeConflictError = "已有面板升级任务执行中"
|
||||
)
|
||||
|
||||
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
|
||||
var systemUpgradeReleaseBaseURL = githubHTMLBase
|
||||
var systemUpgradeAPIBaseURL = githubAPIBase
|
||||
var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||
return client.Get(url)
|
||||
}
|
||||
|
||||
type systemUpgradeExecutor struct {
|
||||
deployDir string
|
||||
backendContainer string
|
||||
}
|
||||
|
||||
type systemUpgradeCapabilityData struct {
|
||||
Capable bool `json:"capable"`
|
||||
Reasons []string `json:"reasons"`
|
||||
DeployDir string `json:"deployDir"`
|
||||
BackendContainer string `json:"backendContainer"`
|
||||
}
|
||||
|
||||
type systemUpgradeReleaseData struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
type systemUpgradeVersionData struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
Channel string `json:"channel"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Capability systemUpgradeCapabilityData `json:"capability"`
|
||||
}
|
||||
|
||||
type systemUpgradeCheckData struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
Channel string `json:"channel"`
|
||||
Capability systemUpgradeCapabilityData `json:"capability"`
|
||||
Releases []systemUpgradeReleaseData `json:"releases"`
|
||||
}
|
||||
|
||||
type systemUpgradeRunData struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
ComposeAsset string `json:"composeAsset"`
|
||||
HelperContainer string `json:"helperContainer"`
|
||||
BackendImageID string `json:"backendImageId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type systemUpgradeRequest struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
func newSystemUpgradeExecutor() *systemUpgradeExecutor {
|
||||
deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv))
|
||||
if deployDir == "" {
|
||||
deployDir = defaultPanelDeployDir
|
||||
}
|
||||
backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv))
|
||||
if backendContainer == "" {
|
||||
backendContainer = defaultPanelBackendName
|
||||
}
|
||||
return &systemUpgradeExecutor{deployDir: deployDir, backendContainer: backendContainer}
|
||||
}
|
||||
|
||||
func currentPanelVersion() string {
|
||||
version := strings.TrimSpace(os.Getenv("FLUX_VERSION"))
|
||||
if version == "" {
|
||||
return "dev"
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func validateBackendContainerName(value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("backend container name is empty")
|
||||
}
|
||||
if !safeBackendContainerPattern.MatchString(value) {
|
||||
return fmt.Errorf("unsafe backend container name: %s", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUpgradeVersion(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("upgrade version is empty")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("unsafe upgrade version: contains control character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) composePath() string {
|
||||
return filepath.Join(e.deployDir, "docker-compose.yml")
|
||||
}
|
||||
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
|
||||
|
||||
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
|
||||
reasons := make([]string, 0)
|
||||
if !filepath.IsAbs(e.deployDir) {
|
||||
reasons = append(reasons, "部署目录必须是绝对路径")
|
||||
}
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil {
|
||||
reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
||||
}
|
||||
if info, err := os.Stat(dockerSocketPath); err != nil {
|
||||
reasons = append(reasons, "docker socket不可用: "+err.Error())
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "docker socket路径不是文件")
|
||||
}
|
||||
if info, err := os.Stat(e.composePath()); err != nil {
|
||||
reasons = append(reasons, "部署docker-compose.yml不可用: "+err.Error())
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "部署docker-compose.yml不是文件")
|
||||
}
|
||||
if info, err := os.Stat(e.envPath()); err != nil {
|
||||
reasons = append(reasons, "部署.env不可用: "+err.Error())
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "部署.env不是文件")
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil {
|
||||
reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
||||
}
|
||||
if _, err := e.currentBackendImage(ctx); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
|
||||
return systemUpgradeCapabilityData{
|
||||
Capable: len(reasons) == 0,
|
||||
Reasons: reasons,
|
||||
DeployDir: e.deployDir,
|
||||
BackendContainer: e.backendContainer,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
|
||||
if enableIPv6ComposePattern.Match(current) {
|
||||
return "docker-compose-v6.yml"
|
||||
}
|
||||
return "docker-compose-v4.yml"
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) helperScript() string {
|
||||
return `set -eu
|
||||
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
|
||||
|
||||
cd "$PANEL_DEPLOY_DIR"
|
||||
echo "" > "$LOGFILE"
|
||||
log "开始面板升级"
|
||||
log "工作目录: $(pwd)"
|
||||
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
log "错误: docker-compose.yml 不存在"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f .env ]; then
|
||||
log "错误: .env 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "拉取新镜像..."
|
||||
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
|
||||
log "错误: 拉取镜像失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "等待旧容器释放资源..."
|
||||
sleep 3
|
||||
|
||||
log "重启服务(force-recreate)..."
|
||||
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
|
||||
log "错误: 重启服务失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "升级完成"
|
||||
`
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{
|
||||
"run", "-d", "--rm", "--name", helperName,
|
||||
"--volumes-from", e.backendContainer,
|
||||
"-v", dockerSocketPath + ":" + dockerSocketPath,
|
||||
"-e", panelDeployDirEnv + "=" + e.deployDir,
|
||||
"--entrypoint", "/bin/sh", imageID,
|
||||
"-c", e.helperScript(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
|
||||
if err := validateUpgradeVersion(version); err != nil {
|
||||
return err
|
||||
}
|
||||
mode, err := fileModeOrDefault(envPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "FLUX_VERSION=") {
|
||||
lines[i] = "FLUX_VERSION=" + version
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
||||
if trimmed == "" {
|
||||
trimmed = "FLUX_VERSION=" + version
|
||||
} else {
|
||||
trimmed += "\nFLUX_VERSION=" + version
|
||||
}
|
||||
return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode)
|
||||
}
|
||||
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
|
||||
return writeFileWithMode(envPath, []byte(content), mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) backupFile(path string) (string, error) {
|
||||
mode, err := fileModeOrDefault(path, 0o600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backupPath := path + ".upgrade.bak"
|
||||
if err := writeFileWithMode(backupPath, data, mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) restoreBackup(path string) error {
|
||||
backupPath := path + ".upgrade.bak"
|
||||
mode, err := fileModeOrDefault(backupPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileWithMode(path, data, mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error {
|
||||
var errs []string
|
||||
for _, path := range paths {
|
||||
if err := e.restoreBackup(path); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return fmt.Errorf("compose asset is empty")
|
||||
}
|
||||
mode, err := fileModeOrDefault(path, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileWithMode(path, data, mode)
|
||||
}
|
||||
|
||||
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
|
||||
if enabled {
|
||||
return fmt.Sprintf("%s/%s", proxyURL, base)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (h *Handler) fetchSystemUpgradeReleases(perPage int) ([]githubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
url := fmt.Sprintf("%s/repos/%s/releases?per_page=%d", strings.TrimRight(systemUpgradeAPIBaseURL, "/"), githubRepo, perPage)
|
||||
if enabled, proxyURL := h.getGithubProxyConfig(); enabled {
|
||||
url = fmt.Sprintf("%s/%s", proxyURL, url)
|
||||
}
|
||||
|
||||
resp, err := systemUpgradeHTTPGet(client, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (string, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
releases, err := h.fetchSystemUpgradeReleases(50)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if releaseChannelFromTag(tag) == normalizedChannel {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||
}
|
||||
|
||||
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fallback, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return info.Mode().Perm(), nil
|
||||
}
|
||||
|
||||
func writeFileWithMode(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.WriteFile(path, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
imageID := strings.TrimSpace(string(out))
|
||||
if imageID == "" {
|
||||
return "", fmt.Errorf("backend image id is empty")
|
||||
}
|
||||
return imageID, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) {
|
||||
args, err := e.buildHelperRunArgs(imageID, helperName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
containerID := strings.TrimSpace(string(out))
|
||||
if containerID == "" {
|
||||
containerID = helperName
|
||||
}
|
||||
return containerID, nil
|
||||
}
|
||||
|
||||
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
|
||||
url := h.buildSystemUpgradeDownloadURL(version, filename)
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := systemUpgradeHTTPGet(client, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("下载%s返回 %d: %s", filename, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSystemUpgradeComposeAssetBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取%s失败: %v", filename, err)
|
||||
}
|
||||
if len(body) > maxSystemUpgradeComposeAssetBytes {
|
||||
return nil, fmt.Errorf("下载%s过大", filename)
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil, fmt.Errorf("下载%s内容为空", filename)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func releasesForChannel(releases []githubRelease, channel string) []systemUpgradeReleaseData {
|
||||
channel = normalizeReleaseChannel(channel)
|
||||
items := make([]systemUpgradeReleaseData, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
itemChannel := releaseChannelFromTag(tag)
|
||||
if itemChannel != channel {
|
||||
continue
|
||||
}
|
||||
items = append(items, systemUpgradeReleaseData{
|
||||
Version: tag,
|
||||
Name: r.Name,
|
||||
PublishedAt: r.PublishedAt,
|
||||
Prerelease: itemChannel == releaseChannelDev,
|
||||
Channel: itemChannel,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(req)
|
||||
}
|
||||
|
||||
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
|
||||
data := systemUpgradeVersionData{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
HasUpdate: latest != "" && latest != current,
|
||||
Channel: channel,
|
||||
Capability: capability,
|
||||
}
|
||||
if lookupErr != nil {
|
||||
data.LatestVersion = ""
|
||||
data.HasUpdate = false
|
||||
data.Reason = lookupErr.Error()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := releaseChannelStable
|
||||
current := currentPanelVersion()
|
||||
exec := newSystemUpgradeExecutor()
|
||||
capability := exec.capability(r.Context())
|
||||
latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel)
|
||||
response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability)))
|
||||
}
|
||||
|
||||
func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req systemUpgradeRequest
|
||||
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
current := currentPanelVersion()
|
||||
exec := newSystemUpgradeExecutor()
|
||||
capability := exec.capability(r.Context())
|
||||
|
||||
githubReleases, err := h.fetchSystemUpgradeReleases(50)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
releases := releasesForChannel(githubReleases, channel)
|
||||
latest := ""
|
||||
if len(releases) > 0 {
|
||||
latest = releases[0].Version
|
||||
}
|
||||
response.WriteJSON(w, response.OK(systemUpgradeCheckData{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
HasUpdate: latest != "" && latest != current,
|
||||
Channel: channel,
|
||||
Capability: capability,
|
||||
Releases: releases,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.systemUpgradeMu.TryLock() {
|
||||
response.WriteJSON(w, response.ErrDefault(systemUpgradeConflictError))
|
||||
return
|
||||
}
|
||||
defer h.systemUpgradeMu.Unlock()
|
||||
|
||||
var req systemUpgradeRequest
|
||||
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
exec := newSystemUpgradeExecutor()
|
||||
capability := exec.capability(r.Context())
|
||||
if !capability.Capable {
|
||||
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = h.resolveSystemUpgradeLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
imageID, err := exec.currentBackendImage(r.Context())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
composePath := exec.composePath()
|
||||
envPath := exec.envPath()
|
||||
composeData, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
composeAsset := exec.selectComposeAsset(composeData)
|
||||
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := exec.backupFile(composePath); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := exec.backupFile(envPath); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := exec.replaceCompose(composePath, newCompose); err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
|
||||
helperContainer, err := exec.startHelper(r.Context(), imageID, helperName)
|
||||
if err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(systemUpgradeRunData{
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
ComposeAsset: composeAsset,
|
||||
HelperContainer: helperContainer,
|
||||
BackendImageID: imageID,
|
||||
Message: systemUpgradeMessage,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
compose := []byte("networks:\n gost-network:\n enable_ipv6: true\n")
|
||||
|
||||
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
|
||||
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v6.yml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadReleaseAssetUsesGithubProxyWhenEnabled(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
repoStore, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Open() error = %v", err)
|
||||
}
|
||||
defer repoStore.Close()
|
||||
|
||||
h := &Handler{repo: repoStore}
|
||||
originalBase := systemUpgradeReleaseBaseURL
|
||||
systemUpgradeReleaseBaseURL = "https://example.invalid"
|
||||
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
||||
|
||||
originalGet := systemUpgradeHTTPGet
|
||||
defer func() { systemUpgradeHTTPGet = originalGet }()
|
||||
|
||||
var gotURL string
|
||||
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||
gotURL = url
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("services:\n backend:\n image: test\n")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repoStore.UpsertConfig("github_proxy_enabled", "true", now); err != nil {
|
||||
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
|
||||
}
|
||||
if err := repoStore.UpsertConfig("github_proxy_url", "https://proxy.example.com", now); err != nil {
|
||||
t.Fatalf("UpsertConfig() github_proxy_url error = %v", err)
|
||||
}
|
||||
|
||||
data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadReleaseAsset() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "backend") {
|
||||
t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data))
|
||||
}
|
||||
|
||||
wantURL := "https://proxy.example.com/https://example.invalid/Sagit-chu/flvx/releases/download/2.1.9/docker-compose-v4.yml"
|
||||
if gotURL != wantURL {
|
||||
t.Fatalf("download URL = %q, want %q", gotURL, wantURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
repoStore, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Open() error = %v", err)
|
||||
}
|
||||
defer repoStore.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repoStore.UpsertConfig("github_proxy_enabled", "false", now); err != nil {
|
||||
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
|
||||
}
|
||||
|
||||
originalBase := systemUpgradeReleaseBaseURL
|
||||
systemUpgradeReleaseBaseURL = "https://example.invalid"
|
||||
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
||||
|
||||
originalGet := systemUpgradeHTTPGet
|
||||
defer func() { systemUpgradeHTTPGet = originalGet }()
|
||||
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
h := &Handler{repo: repoStore}
|
||||
_, err = h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
|
||||
if err == nil || !strings.Contains(err.Error(), "过大") {
|
||||
t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectComposeAssetUsesIPv6TemplateForYAMLVariants(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
for _, compose := range [][]byte{
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6:true\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: True\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: \"true\"\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: 'true'\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: true # comment\n"),
|
||||
} {
|
||||
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
|
||||
t.Fatalf("selectComposeAsset(%q) = %q, want %q", string(compose), got, "docker-compose-v6.yml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
compose := []byte("services:\n backend:\n image: test\n")
|
||||
|
||||
if got := exec.selectComposeAsset(compose); got != "docker-compose-v4.yml" {
|
||||
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v4.yml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) {
|
||||
for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
original := []byte("JWT_SECRET=test\n")
|
||||
if err := os.WriteFile(envPath, original, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err == nil {
|
||||
t.Fatal("expected unsafe version to fail validation")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != string(original) {
|
||||
t.Fatalf("env content changed to %q, want %q", string(data), string(original))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) {
|
||||
for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionPreservesFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("env mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
|
||||
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
|
||||
t.Fatal("expected unsafe container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
|
||||
if err != nil {
|
||||
t.Fatalf("buildHelperRunArgs() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"run", "-d", "--rm", "--name", "flvx-upgrade-helper",
|
||||
"--volumes-from", "flux-panel-backend",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
||||
"--entrypoint", "/bin/sh", "sha256:abc",
|
||||
"-c", exec.helperScript(),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"}
|
||||
if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil {
|
||||
t.Fatal("expected unsafe backend container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.systemVersion(rr, req)
|
||||
|
||||
if !strings.Contains(rr.Body.String(), "请求失败") {
|
||||
t.Fatalf("expected wrong-method response, got %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpgradeRejectsConcurrentRequests(t *testing.T) {
|
||||
h := &Handler{}
|
||||
h.systemUpgradeMu.Lock()
|
||||
defer h.systemUpgradeMu.Unlock()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.systemUpgrade(rr, req)
|
||||
|
||||
if !strings.Contains(rr.Body.String(), systemUpgradeConflictError) {
|
||||
t.Fatalf("expected conflict message, got %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composePath := filepath.Join(dir, "docker-compose.yml")
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(composePath, []byte("services:\n backend:\n image: test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() compose error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() env error = %v", err)
|
||||
}
|
||||
|
||||
fakeDockerDir := t.TempDir()
|
||||
fakeDockerPath := filepath.Join(fakeDockerDir, "docker")
|
||||
fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;&\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;&\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;&\n *)\n exit 0\n ;;&\n esac\n"
|
||||
if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile() fake docker error = %v", err)
|
||||
}
|
||||
t.Setenv("PATH", fakeDockerDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv(panelDeployDirEnv, dir)
|
||||
t.Setenv(panelBackendContainerEnv, "flux-panel-backend")
|
||||
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.systemUpgrade(rr, req)
|
||||
|
||||
if !strings.Contains(rr.Body.String(), "当前环境不支持面板自升级") {
|
||||
t.Fatalf("expected fail-fast capability error, got %s", rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(composePath + ".upgrade.bak"); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no compose backup, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(envPath + ".upgrade.bak"); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no env backup, got err=%v", err)
|
||||
}
|
||||
composeData, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() compose error = %v", err)
|
||||
}
|
||||
if string(composeData) != "services:\n backend:\n image: test\n" {
|
||||
t.Fatalf("compose mutated unexpectedly: %q", string(composeData))
|
||||
}
|
||||
envData, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() env error = %v", err)
|
||||
}
|
||||
if string(envData) != "FLUX_VERSION=2.1.8\nJWT_SECRET=test\n" {
|
||||
t.Fatalf("env mutated unexpectedly: %q", string(envData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "docker-compose.yml")
|
||||
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
backupPath, err := exec.backupFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if backupPath != path+".upgrade.bak" {
|
||||
t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "original" {
|
||||
t.Fatalf("restored content = %q, want original", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if _, err := exec.backupFile(path); err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("restored mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
|
||||
var payload systemUpgradeRequest
|
||||
|
||||
if err := decodeSystemUpgradeRequest(req, &payload); err == nil {
|
||||
t.Fatal("expected truncated JSON to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSystemUpgradeRequestAllowsEmptyBody(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(""))
|
||||
var payload systemUpgradeRequest
|
||||
|
||||
if err := decodeSystemUpgradeRequest(req, &payload); err != nil {
|
||||
t.Fatalf("expected empty body to be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpgradeVersionDataSurfacesLookupFailureReason(t *testing.T) {
|
||||
data, err := json.Marshal(systemUpgradeVersionData{Reason: "GitHub unavailable"})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"reason":"GitHub unavailable"`) {
|
||||
t.Fatalf("expected reason field in JSON, got %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
@@ -14,7 +15,8 @@ type contextKey string
|
||||
const ClaimsContextKey contextKey = "claims"
|
||||
|
||||
type AuthOptions struct {
|
||||
JWTSecret string
|
||||
JWTSecret string
|
||||
GetUserAuthState func(userID int64) (*auth.UserAuthState, error)
|
||||
}
|
||||
|
||||
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
||||
@@ -32,16 +34,45 @@ func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
||||
|
||||
token := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if token == "" {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := auth.ValidateToken(token, opts.JWTSecret)
|
||||
if !ok {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
if opts.GetUserAuthState != nil {
|
||||
userID, err := strconv.ParseInt(claims.Sub, 10, 64)
|
||||
if err != nil {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
state, err := opts.GetUserAuthState(userID)
|
||||
if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if requiresAdmin(r.URL.Path) && claims.RoleID != 0 {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||
return
|
||||
@@ -69,6 +100,10 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func allowsOptionalAuth(path string) bool {
|
||||
return path == "/api/v1/config/get"
|
||||
}
|
||||
|
||||
func shouldSkip(path string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/flow/"):
|
||||
@@ -78,9 +113,11 @@ func shouldSkip(path string) bool {
|
||||
case strings.HasPrefix(path, "/api/v1/captcha/"):
|
||||
return true
|
||||
case path == "/api/v1/config/get":
|
||||
return true
|
||||
return false
|
||||
case path == "/api/v1/user/login":
|
||||
return true
|
||||
case path == "/api/v1/public/config/get":
|
||||
return true
|
||||
case path == "/api/v1/federation/connect":
|
||||
return true
|
||||
case path == "/api/v1/federation/tunnel/create":
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestJWTRejectsPasswordChangedToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs + 1}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTAcceptsCurrentUserState(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
|
||||
func TestJWTRejectsDisabledUserToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 0, PasswordChangedAt: time.Now().Unix()}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsPasswordChangedAtSameMillisecond(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsRoleMismatch(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsMissingUserState(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsAuthStateLookupError(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestShouldSkipDoesNotBypassConfigGet(t *testing.T) {
|
||||
if shouldSkip("/api/v1/config/get") {
|
||||
t.Fatal("expected /api/v1/config/get to require auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
|
||||
if !shouldSkip("/api/v1/public/config/get") {
|
||||
t.Fatal("expected /api/v1/public/config/get to remain public")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTExpiresAfterSevenDays(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
if got := claims.Exp - claims.Iat; got != int64(7*24*time.Hour/time.Second) {
|
||||
t.Fatalf("expected 7 day token lifetime, got %d seconds", got)
|
||||
}
|
||||
if claims.IatMs <= 0 {
|
||||
t.Fatalf("expected millisecond issuance time to be populated, got %d", claims.IatMs)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expected {
|
||||
t.Fatalf("expected code %d, got %d", expected, out.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuthDenied(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
assertCodeMsg(t, rec, 401, "无效的token或token已过期")
|
||||
}
|
||||
|
||||
func assertCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expectedCode || out.Msg != expectedMsg {
|
||||
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func NewRouter(h *handler.Handler, jwtSecret string) http.Handler {
|
||||
mux.Handle("/system-info", h.WebSocketHandler())
|
||||
|
||||
wrapped := middleware.Recover(mux)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret})(wrapped)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret, GetUserAuthState: h.GetUserAuthState})(wrapped)
|
||||
wrapped = middleware.RequestLog(wrapped)
|
||||
wrapped = middleware.CORS(wrapped)
|
||||
return wrapped
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func HashPassword(plain string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(storedHash, plain string) (bool, bool) {
|
||||
if strings.HasPrefix(storedHash, "$2") {
|
||||
return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) == nil, false
|
||||
}
|
||||
if MD5(plain) == storedHash {
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func IsLegacyPasswordHash(storedHash string) bool {
|
||||
storedHash = strings.TrimSpace(storedHash)
|
||||
if len(storedHash) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range storedHash {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPasswordProducesBcrypt(t *testing.T) {
|
||||
hash, err := HashPassword("admin_user")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
if len(hash) < 50 || !strings.HasPrefix(hash, "$2") {
|
||||
t.Fatalf("expected bcrypt hash, got %q", hash)
|
||||
}
|
||||
if ok, legacy := VerifyPassword(hash, "admin_user"); !ok || legacy {
|
||||
t.Fatalf("VerifyPassword() = (%v,%v), want (true,false)", ok, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordAcceptsLegacyMD5(t *testing.T) {
|
||||
if ok, legacy := VerifyPassword("3c85cdebade1c51cf64ca9f3c09d182d", "admin_user"); !ok || !legacy {
|
||||
t.Fatalf("VerifyPassword() = (%v,%v), want (true,true)", ok, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLegacyPasswordHash(t *testing.T) {
|
||||
if !IsLegacyPasswordHash("3c85cdebade1c51cf64ca9f3c09d182d") {
|
||||
t.Fatal("expected 32-char hex MD5 hash to be legacy")
|
||||
}
|
||||
hash, err := HashPassword("admin_user")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
if IsLegacyPasswordHash(hash) {
|
||||
t.Fatalf("expected bcrypt hash not to be legacy: %q", hash)
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,21 @@ import "database/sql"
|
||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||
// word, so TableName() is required for correct quoting.
|
||||
type User struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||
Pwd string `gorm:"type:varchar(100);not null"`
|
||||
RoleID int `gorm:"column:role_id;not null"`
|
||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||
Num int `gorm:"not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||
Pwd string `gorm:"type:varchar(100);not null"`
|
||||
RoleID int `gorm:"column:role_id;not null"`
|
||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||
Num int `gorm:"not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
PasswordChangedAt int64 `gorm:"column:password_changed_at;not null;default:0"`
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "user" }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package repo
|
||||
|
||||
import "strings"
|
||||
|
||||
type ConfigAccessPolicy string
|
||||
|
||||
const (
|
||||
ConfigAccessPublic ConfigAccessPolicy = "public"
|
||||
ConfigAccessSensitive ConfigAccessPolicy = "sensitive"
|
||||
)
|
||||
|
||||
var publicConfigKeys = map[string]struct{}{
|
||||
"app_name": {},
|
||||
"app_logo": {},
|
||||
"app_favicon": {},
|
||||
"app_bg_image": {},
|
||||
"cloudflare_site_key": {},
|
||||
}
|
||||
|
||||
var sensitiveConfigKeys = map[string]struct{}{
|
||||
"jwt_secret": {},
|
||||
"license_key": {},
|
||||
"cloudflare_secret_key": {},
|
||||
}
|
||||
|
||||
func PolicyForConfig(name string) ConfigAccessPolicy {
|
||||
if IsPublicConfigKey(name) {
|
||||
return ConfigAccessPublic
|
||||
}
|
||||
if IsSensitiveConfigKey(name) {
|
||||
return ConfigAccessSensitive
|
||||
}
|
||||
return ConfigAccessSensitive
|
||||
}
|
||||
|
||||
func IsPublicConfigKey(name string) bool {
|
||||
_, ok := publicConfigKeys[normalizeConfigKey(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsSensitiveConfigKey(name string) bool {
|
||||
_, ok := sensitiveConfigKeys[normalizeConfigKey(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for name, value := range in {
|
||||
if IsSensitiveConfigKey(name) {
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeConfigKey(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfigPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want ConfigAccessPolicy
|
||||
}{
|
||||
{name: "app_name is public", key: "app_name", want: ConfigAccessPublic},
|
||||
{name: "app_logo is public", key: "app_logo", want: ConfigAccessPublic},
|
||||
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
|
||||
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
|
||||
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
|
||||
{name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive},
|
||||
{name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive},
|
||||
{name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive},
|
||||
{name: "trimmed public key is public", key: " APP_NAME ", want: ConfigAccessPublic},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := PolicyForConfig(tt.key); got != tt.want {
|
||||
t.Fatalf("PolicyForConfig(%q) = %v, want %v", tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPolicyHelpers(t *testing.T) {
|
||||
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key"}
|
||||
for _, key := range publicKeys {
|
||||
if !IsPublicConfigKey(key) {
|
||||
t.Fatalf("expected %q to be public", key)
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveKeys := []string{"jwt_secret", "license_key", "cloudflare_secret_key"}
|
||||
for _, key := range sensitiveKeys {
|
||||
if !IsSensitiveConfigKey(key) {
|
||||
t.Fatalf("expected %q to be sensitive", key)
|
||||
}
|
||||
}
|
||||
|
||||
input := map[string]string{
|
||||
"app_name": "FLVX",
|
||||
"license_key": "secret-license",
|
||||
"cloudflare_secret_key": "secret-cloudflare",
|
||||
"jwt_secret": "secret-jwt",
|
||||
"cloudflare_site_key": "site-key",
|
||||
}
|
||||
filtered := FilterSensitiveConfigs(input)
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("expected 2 public configs, got %d", len(filtered))
|
||||
}
|
||||
if filtered["app_name"] != "FLVX" || filtered["cloudflare_site_key"] != "site-key" {
|
||||
t.Fatalf("unexpected filtered configs: %+v", filtered)
|
||||
}
|
||||
if _, ok := filtered["jwt_secret"]; ok {
|
||||
t.Fatal("expected jwt_secret to be filtered out")
|
||||
}
|
||||
if _, ok := filtered["license_key"]; ok {
|
||||
t.Fatal("expected license_key to be filtered out")
|
||||
}
|
||||
if _, ok := filtered["cloudflare_secret_key"]; ok {
|
||||
t.Fatal("expected cloudflare_secret_key to be filtered out")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
@@ -304,6 +307,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
hasNode := m.HasTable(&model.Node{})
|
||||
hasTunnel := m.HasTable(&model.Tunnel{})
|
||||
hasForward := m.HasTable(&model.Forward{})
|
||||
|
||||
for _, item := range models {
|
||||
if hasNode {
|
||||
@@ -316,6 +320,11 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if hasForward {
|
||||
if _, ok := item.(*model.Forward); ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -396,7 +405,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
if m.HasTable(&model.Forward{}) {
|
||||
for _, field := range []string{"ProxyProtocol"} {
|
||||
for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} {
|
||||
if m.HasColumn(&model.Forward{}, field) {
|
||||
continue
|
||||
}
|
||||
@@ -410,13 +419,23 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func seedData(db *gorm.DB) {
|
||||
adminUser := model.User{
|
||||
ID: 1, User: "admin_user", Pwd: "3c85cdebade1c51cf64ca9f3c09d182d",
|
||||
RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,
|
||||
FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,
|
||||
UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,
|
||||
var adminCount int64
|
||||
if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 {
|
||||
adminPwd, err := security.HashPassword("admin_user")
|
||||
if err != nil {
|
||||
log.Printf("seed admin password hash failed: %v", err)
|
||||
} else {
|
||||
adminUser := model.User{
|
||||
ID: 1, User: "admin_user", Pwd: adminPwd,
|
||||
RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,
|
||||
FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,
|
||||
UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true},
|
||||
Status: 1,
|
||||
PasswordChangedAt: 1748914865000,
|
||||
}
|
||||
db.Create(&adminUser)
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", 1).FirstOrCreate(&adminUser)
|
||||
|
||||
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
||||
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
||||
@@ -480,9 +499,10 @@ func (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordM
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"user": username,
|
||||
"pwd": passwordMD5,
|
||||
"updated_time": now,
|
||||
"user": username,
|
||||
"pwd": passwordMD5,
|
||||
"password_changed_at": now,
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -1884,7 +1904,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||
}
|
||||
backup.Configs = configs
|
||||
backup.Configs = FilterSensitiveConfigs(configs)
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
@@ -1964,7 +1984,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||
}
|
||||
backup.Configs = v
|
||||
backup.Configs = FilterSensitiveConfigs(v)
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
@@ -2346,26 +2366,31 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {
|
||||
count := 0
|
||||
for _, u := range users {
|
||||
item := model.User{
|
||||
ID: u.ID,
|
||||
User: u.User,
|
||||
Pwd: u.Pwd,
|
||||
RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime,
|
||||
Flow: u.Flow,
|
||||
InFlow: u.InFlow,
|
||||
OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime,
|
||||
Num: u.Num,
|
||||
CreatedTime: u.CreatedTime,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: u.Status,
|
||||
pwdHash, status, err := normalizeImportedUserPassword(u.Pwd, u.Status)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
item := model.User{
|
||||
ID: u.ID,
|
||||
User: u.User,
|
||||
Pwd: pwdHash,
|
||||
RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime,
|
||||
Flow: u.Flow,
|
||||
InFlow: u.InFlow,
|
||||
OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime,
|
||||
Num: u.Num,
|
||||
CreatedTime: u.CreatedTime,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
err = tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
||||
}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
@@ -2409,6 +2434,33 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func normalizeImportedUserPassword(password string, status int) (string, int, error) {
|
||||
password = strings.TrimSpace(password)
|
||||
if strings.HasPrefix(password, "$2") {
|
||||
return password, status, nil
|
||||
}
|
||||
if security.IsLegacyPasswordHash(password) || password == "" {
|
||||
replacement, err := randomPasswordHash()
|
||||
if err != nil {
|
||||
return "", status, err
|
||||
}
|
||||
return replacement, 0, nil
|
||||
}
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", status, err
|
||||
}
|
||||
return hash, status, nil
|
||||
}
|
||||
|
||||
func randomPasswordHash() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return security.HashPassword(hex.EncodeToString(buf))
|
||||
}
|
||||
|
||||
func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {
|
||||
count := 0
|
||||
for _, n := range nodes {
|
||||
@@ -2720,6 +2772,7 @@ func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int6
|
||||
}
|
||||
|
||||
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
|
||||
configs = FilterSensitiveConfigs(configs)
|
||||
count := 0
|
||||
for name, value := range configs {
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var user struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
RoleID int `gorm:"column:role_id"`
|
||||
Status int `gorm:"column:status"`
|
||||
PasswordChangedAt int64 `gorm:"column:password_changed_at"`
|
||||
}
|
||||
if err := r.db.Model(&model.User{}).Select("id", "role_id", "status", "password_changed_at").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return nil, normalizeNotFoundErr(err)
|
||||
}
|
||||
return &auth.UserAuthState{
|
||||
ID: user.ID,
|
||||
RoleID: user.RoleID,
|
||||
Status: user.Status,
|
||||
PasswordChangedAt: user.PasswordChangedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetUserAuthStateReturnsPasswordChangedAt(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "auth.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
userID, err := r.CreateUser("admin_user", "pwd", 0, 2727251700000, 99999, 1, 99999, 1, 0, now)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
state, err := r.GetUserAuthState(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserAuthState() error = %v", err)
|
||||
}
|
||||
if state == nil || state.PasswordChangedAt != now || state.Status != 1 || state.RoleID != 0 {
|
||||
t.Fatalf("unexpected auth state: %+v", state)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,33 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestSeedDataDefaultAdminUsesBcrypt(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "seed.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
admin, err := r.GetUserByUsername("admin_user")
|
||||
if err != nil {
|
||||
t.Fatalf("get admin user: %v", err)
|
||||
}
|
||||
if admin == nil {
|
||||
t.Fatal("expected seeded admin user")
|
||||
}
|
||||
if security.IsLegacyPasswordHash(admin.Pwd) {
|
||||
t.Fatalf("seeded admin password is legacy MD5: %q", admin.Pwd)
|
||||
}
|
||||
if ok, legacy := security.VerifyPassword(admin.Pwd, "admin_user"); !ok || legacy {
|
||||
t.Fatalf("VerifyPassword() = (%v,%v), want (true,false)", ok, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
|
||||
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
|
||||
if err != nil {
|
||||
@@ -57,3 +82,157 @@ func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
|
||||
t.Fatalf("unexpected imported probe target: %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "export.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
seedConfig(t, r, "app_name", "FLVX")
|
||||
seedConfig(t, r, "app_logo", "logo")
|
||||
seedConfig(t, r, "app_favicon", "favicon")
|
||||
seedConfig(t, r, "app_bg_image", "bg")
|
||||
seedConfig(t, r, "cloudflare_site_key", "site-key")
|
||||
seedConfig(t, r, "jwt_secret", "jwt-secret")
|
||||
seedConfig(t, r, "license_key", "license-secret")
|
||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
export func() (*model.BackupData, error)
|
||||
}{
|
||||
{name: "ExportAll", export: r.ExportAll},
|
||||
{name: "ExportPartial", export: func() (*model.BackupData, error) { return r.ExportPartial([]string{"configs"}) }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
backup, err := tc.export()
|
||||
if err != nil {
|
||||
t.Fatalf("export backup: %v", err)
|
||||
}
|
||||
if backup.Configs["app_name"] != "FLVX" {
|
||||
t.Fatalf("expected public config in export, got %+v", backup.Configs)
|
||||
}
|
||||
if backup.Configs["cloudflare_site_key"] != "site-key" {
|
||||
t.Fatalf("expected public config in export, got %+v", backup.Configs)
|
||||
}
|
||||
for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} {
|
||||
if _, ok := backup.Configs[key]; ok {
|
||||
t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "import.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
seedConfig(t, r, "app_name", "before")
|
||||
seedConfig(t, r, "jwt_secret", "jwt-before")
|
||||
seedConfig(t, r, "license_key", "license-before")
|
||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
|
||||
backup := &model.BackupData{Configs: map[string]string{
|
||||
"app_name": "after",
|
||||
"jwt_secret": "jwt-after",
|
||||
"license_key": "license-after",
|
||||
"cloudflare_secret_key": "cloudflare-after",
|
||||
}}
|
||||
|
||||
result, err := r.Import(backup, []string{"configs"})
|
||||
if err != nil {
|
||||
t.Fatalf("import backup: %v", err)
|
||||
}
|
||||
if result.ConfigsImported != 1 {
|
||||
t.Fatalf("expected one imported config, got %d", result.ConfigsImported)
|
||||
}
|
||||
|
||||
assertConfigValue(t, r, "app_name", "after")
|
||||
assertConfigValue(t, r, "jwt_secret", "jwt-before")
|
||||
assertConfigValue(t, r, "license_key", "license-before")
|
||||
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
}
|
||||
|
||||
func TestImportUsersDoesNotStoreLegacyMD5Passwords(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "legacy-user-import.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
backup := &model.BackupData{
|
||||
Version: "1.0",
|
||||
Users: []model.UserBackup{{
|
||||
ID: 55,
|
||||
User: "legacy-import-user",
|
||||
Pwd: "3c85cdebade1c51cf64ca9f3c09d182d",
|
||||
RoleID: 1,
|
||||
ExpTime: 2727251700000,
|
||||
Flow: 99999,
|
||||
InFlow: 0,
|
||||
OutFlow: 0,
|
||||
FlowResetTime: 1,
|
||||
Num: 99999,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := r.Import(backup, []string{"users"})
|
||||
if err != nil {
|
||||
t.Fatalf("Import() error = %v", err)
|
||||
}
|
||||
if result.UsersImported != 1 {
|
||||
t.Fatalf("UsersImported = %d, want 1", result.UsersImported)
|
||||
}
|
||||
|
||||
user, err := r.GetUserByUsername("legacy-import-user")
|
||||
if err != nil {
|
||||
t.Fatalf("get imported user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatal("expected imported user")
|
||||
}
|
||||
if security.IsLegacyPasswordHash(user.Pwd) {
|
||||
t.Fatalf("imported password remained legacy MD5: %q", user.Pwd)
|
||||
}
|
||||
if ok, _ := security.VerifyPassword(user.Pwd, "admin_user"); ok {
|
||||
t.Fatal("legacy imported password should not remain usable")
|
||||
}
|
||||
if user.Status != 0 {
|
||||
t.Fatalf("legacy imported user status = %d, want disabled status 0", user.Status)
|
||||
}
|
||||
if user.PasswordChangedAt <= 0 {
|
||||
t.Fatalf("PasswordChangedAt = %d, want import revocation timestamp", user.PasswordChangedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func seedConfig(t *testing.T, r *Repository, name, value string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, name, value, time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertConfigValue(t *testing.T, r *Repository, name, want string) {
|
||||
t.Helper()
|
||||
cfg, err := r.GetConfigByName(name)
|
||||
if err != nil {
|
||||
t.Fatalf("get config %s: %v", name, err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != want {
|
||||
t.Fatalf("expected config %s=%q, got %+v", name, want, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,68 @@ func TestOpenBackfillsSQLiteLegacyTunnelProbeTargetColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBackfillsSQLiteLegacyForwardColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-forward.db")
|
||||
db, err := gorm.Open(gsqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE forward (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_name VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
remote_addr TEXT NOT NULL,
|
||||
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
speed_id INTEGER
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy forward table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, speed_id)
|
||||
VALUES(1, 2, 'legacy-user', 'legacy-forward', 3, '127.0.0.1:9000', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert legacy forward: %v", err)
|
||||
}
|
||||
if sqlDB, _ := db.DB(); sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
|
||||
r, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
m := r.DB().Migrator()
|
||||
for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} {
|
||||
if !m.HasColumn(&model.Forward{}, field) {
|
||||
t.Fatalf("expected forward.%s column to exist", field)
|
||||
}
|
||||
}
|
||||
|
||||
var maxConn, ipMaxConn, proxyProtocol int
|
||||
var ipSpeedID sql.NullInt64
|
||||
if err := r.DB().Raw(`SELECT max_conn, ip_max_conn, ip_speed_id, proxy_protocol FROM forward WHERE id = 1`).Row().Scan(&maxConn, &ipMaxConn, &ipSpeedID, &proxyProtocol); err != nil {
|
||||
t.Fatalf("query forward defaults: %v", err)
|
||||
}
|
||||
if maxConn != 0 || ipMaxConn != 0 || ipSpeedID.Valid || proxyProtocol != 0 {
|
||||
t.Fatalf("expected default forward columns 0/0/NULL/0, got max_conn=%d ip_max_conn=%d ip_speed_id=%+v proxy_protocol=%d", maxConn, ipMaxConn, ipSpeedID, proxyProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
|
||||
@@ -42,19 +42,20 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
user := model.User{
|
||||
User: username,
|
||||
Pwd: pwdHash,
|
||||
RoleID: roleID,
|
||||
ExpTime: expTime,
|
||||
Flow: flow,
|
||||
InFlow: 0,
|
||||
OutFlow: 0,
|
||||
FlowResetTime: flowResetTime,
|
||||
Num: num,
|
||||
MaxConn: maxConn,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
User: username,
|
||||
Pwd: pwdHash,
|
||||
RoleID: roleID,
|
||||
ExpTime: expTime,
|
||||
Flow: flow,
|
||||
InFlow: 0,
|
||||
OutFlow: 0,
|
||||
FlowResetTime: flowResetTime,
|
||||
Num: num,
|
||||
MaxConn: maxConn,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
return 0, err
|
||||
@@ -81,15 +82,16 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"user": username,
|
||||
"pwd": pwdHash,
|
||||
"flow": flow,
|
||||
"num": num,
|
||||
"exp_time": expTime,
|
||||
"flow_reset_time": flowResetTime,
|
||||
"status": status,
|
||||
"max_conn": maxConn,
|
||||
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
||||
"user": username,
|
||||
"pwd": pwdHash,
|
||||
"flow": flow,
|
||||
"num": num,
|
||||
"exp_time": expTime,
|
||||
"flow_reset_time": flowResetTime,
|
||||
"status": status,
|
||||
"max_conn": maxConn,
|
||||
"password_changed_at": now,
|
||||
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -111,6 +113,19 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserPassword(userID int64, pwdHash string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"pwd": pwdHash,
|
||||
"password_changed_at": now,
|
||||
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64) {
|
||||
if r == nil || r.db == nil {
|
||||
return
|
||||
|
||||
@@ -42,6 +42,18 @@ type nodeSession struct {
|
||||
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
|
||||
}
|
||||
|
||||
type adminSession struct {
|
||||
userID int64
|
||||
claims auth.Claims
|
||||
conn *connWrap
|
||||
}
|
||||
|
||||
type monitorSession struct {
|
||||
userID int64
|
||||
claims auth.Claims
|
||||
conn *connWrap
|
||||
}
|
||||
|
||||
type commandResponse struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
@@ -74,12 +86,14 @@ type Server struct {
|
||||
upgrader websocket.Upgrader
|
||||
onNodeOnline func(nodeID int64)
|
||||
onNodeMetric func(nodeID int64, info SystemInfo)
|
||||
getUserAuthState func(userID int64) (*auth.UserAuthState, error)
|
||||
|
||||
mu sync.RWMutex
|
||||
admins map[*connWrap]struct{}
|
||||
nodes map[int64]*nodeSession
|
||||
byConn map[*websocket.Conn]*nodeSession
|
||||
pending map[string]pendingRequest
|
||||
admins map[*adminSession]struct{}
|
||||
monitors map[*monitorSession]struct{}
|
||||
nodes map[int64]*nodeSession
|
||||
byConn map[*websocket.Conn]*nodeSession
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
type SystemInfo struct {
|
||||
@@ -123,13 +137,23 @@ func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
admins: make(map[*connWrap]struct{}),
|
||||
nodes: make(map[int64]*nodeSession),
|
||||
byConn: make(map[*websocket.Conn]*nodeSession),
|
||||
pending: make(map[string]pendingRequest),
|
||||
admins: make(map[*adminSession]struct{}),
|
||||
monitors: make(map[*monitorSession]struct{}),
|
||||
nodes: make(map[int64]*nodeSession),
|
||||
byConn: make(map[*websocket.Conn]*nodeSession),
|
||||
pending: make(map[string]pendingRequest),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) SetUserAuthStateLookup(fn func(userID int64) (*auth.UserAuthState, error)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.getUserAuthState = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
typeVal := query.Get("type")
|
||||
@@ -146,18 +170,36 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if typeVal == "0" {
|
||||
if _, ok := auth.ValidateToken(secret, s.jwtSecret); !ok {
|
||||
claims, ok := auth.ValidateToken(secret, s.jwtSecret)
|
||||
if !ok {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.handleAdmin(w, r)
|
||||
userID, err := strconv.ParseInt(claims.Sub, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if claims.RoleID == 0 {
|
||||
if !s.validateAdminSession(userID, claims) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.handleAdmin(w, r, userID, claims)
|
||||
return
|
||||
}
|
||||
if !s.validateMonitorSession(userID, claims) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.handleMonitor(w, r, userID, claims)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request, userID int64, claims auth.Claims) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -168,16 +210,54 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go startKeepalive(cw, done)
|
||||
session := &adminSession{userID: userID, claims: claims, conn: cw}
|
||||
go startKeepalive(cw, done, func() bool {
|
||||
return s.validateAdminSession(session.userID, session.claims)
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
s.admins[cw] = struct{}{}
|
||||
s.admins[session] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
s.mu.Lock()
|
||||
delete(s.admins, cw)
|
||||
delete(s.admins, session)
|
||||
s.mu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMonitor(w http.ResponseWriter, r *http.Request, userID int64, claims auth.Claims) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
session := &monitorSession{userID: userID, claims: claims, conn: cw}
|
||||
go startKeepalive(cw, done, func() bool {
|
||||
return s.validateMonitorSession(session.userID, session.claims)
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
s.monitors[session] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
s.mu.Lock()
|
||||
delete(s.monitors, session)
|
||||
s.mu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
@@ -200,7 +280,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go startKeepalive(cw, done)
|
||||
go startKeepalive(cw, done, nil)
|
||||
|
||||
version := r.URL.Query().Get("version")
|
||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
||||
@@ -237,7 +317,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
needOfflineBroadcast := false
|
||||
s.mu.Lock()
|
||||
current, ok := s.nodes[nodeID]
|
||||
if ok && current.conn.conn == conn {
|
||||
if ok && current.conn != nil && current.conn.conn == conn {
|
||||
delete(s.nodes, nodeID)
|
||||
needOfflineBroadcast = true
|
||||
}
|
||||
@@ -417,11 +497,7 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
||||
}
|
||||
}
|
||||
|
||||
ns.conn.mu.Lock()
|
||||
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
||||
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
|
||||
ns.conn.mu.Unlock()
|
||||
err = writeWSMessage(ns.conn, websocket.TextMessage, messageData)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
@@ -537,35 +613,47 @@ func (s *Server) broadcastStatus(nodeID int64, status int) {
|
||||
"data": status,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
s.broadcastToAdmins(string(raw))
|
||||
s.broadcastToRealtime(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastInfo(nodeID int64, data string) {
|
||||
payload := broadcastMessage{ID: nodeID, Type: "info", Data: data}
|
||||
raw, _ := json.Marshal(payload)
|
||||
s.broadcastToAdmins(string(raw))
|
||||
s.broadcastToRealtime(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) {
|
||||
payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data}
|
||||
raw, _ := json.Marshal(payload)
|
||||
s.broadcastToAdmins(string(raw))
|
||||
s.broadcastToRealtime(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastToAdmins(message string) {
|
||||
func (s *Server) broadcastToRealtime(message string) {
|
||||
s.mu.RLock()
|
||||
admins := make([]*connWrap, 0, len(s.admins))
|
||||
admins := make([]*adminSession, 0, len(s.admins))
|
||||
for c := range s.admins {
|
||||
admins = append(admins, c)
|
||||
}
|
||||
monitors := make([]*monitorSession, 0, len(s.monitors))
|
||||
for c := range s.monitors {
|
||||
monitors = append(monitors, c)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, c := range admins {
|
||||
c.mu.Lock()
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||
c.mu.Unlock()
|
||||
if c == nil || c.conn == nil || c.conn.conn == nil {
|
||||
continue
|
||||
}
|
||||
err := writeWSMessage(c.conn, websocket.TextMessage, []byte(message))
|
||||
if err != nil {
|
||||
log.Printf("websocket broadcast failed: %v", err)
|
||||
}
|
||||
}
|
||||
for _, c := range monitors {
|
||||
if c == nil || c.conn == nil || c.conn.conn == nil {
|
||||
continue
|
||||
}
|
||||
err := writeWSMessage(c.conn, websocket.TextMessage, []byte(message))
|
||||
if err != nil {
|
||||
log.Printf("websocket broadcast failed: %v", err)
|
||||
}
|
||||
@@ -602,7 +690,60 @@ func parseIntDefault(v string, fallback int) int {
|
||||
return x
|
||||
}
|
||||
|
||||
func startKeepalive(cw *connWrap, done <-chan struct{}) {
|
||||
func (s *Server) validateAdminSession(userID int64, claims auth.Claims) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Exp <= time.Now().Unix() {
|
||||
return false
|
||||
}
|
||||
if s.getUserAuthState == nil {
|
||||
return true
|
||||
}
|
||||
state, err := s.getUserAuthState(userID)
|
||||
if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) validateMonitorSession(userID int64, claims auth.Claims) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Exp <= time.Now().Unix() {
|
||||
return false
|
||||
}
|
||||
if s.getUserAuthState != nil {
|
||||
state, err := s.getUserAuthState(userID)
|
||||
if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if s.repo == nil {
|
||||
return false
|
||||
}
|
||||
allowed, err := s.repo.HasMonitorPermission(userID)
|
||||
if err != nil || !allowed {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeWSMessage(cw *connWrap, messageType int, payload []byte) error {
|
||||
if cw == nil || cw.conn == nil {
|
||||
return errors.New("websocket connection not initialized")
|
||||
}
|
||||
cw.mu.Lock()
|
||||
defer cw.mu.Unlock()
|
||||
|
||||
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := cw.conn.WriteMessage(messageType, payload)
|
||||
_ = cw.conn.SetWriteDeadline(time.Time{})
|
||||
return err
|
||||
}
|
||||
|
||||
func startKeepalive(cw *connWrap, done <-chan struct{}, validate func() bool) {
|
||||
if cw == nil || cw.conn == nil {
|
||||
return
|
||||
}
|
||||
@@ -614,11 +755,11 @@ func startKeepalive(cw *connWrap, done <-chan struct{}) {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cw.mu.Lock()
|
||||
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
_ = cw.conn.SetWriteDeadline(time.Time{})
|
||||
cw.mu.Unlock()
|
||||
if validate != nil && !validate() {
|
||||
_ = cw.conn.Close()
|
||||
return
|
||||
}
|
||||
err := writeWSMessage(cw, websocket.PingMessage, nil)
|
||||
if err != nil {
|
||||
_ = cw.conn.Close()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/store/repo"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestServeHTTPRejectsDisabledAdminToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(nil, secret)
|
||||
server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 0, PasswordChangedAt: 0}, nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/system-info?type=0&secret="+url.QueryEscape(token), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden for disabled admin token, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPRejectsNonAdminToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(nil, secret)
|
||||
server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/system-info?type=0&secret="+url.QueryEscape(token), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden for non-admin token, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdminSessionRejectsAuthStateChanges(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state *auth.UserAuthState
|
||||
}{
|
||||
{name: "disabled", state: &auth.UserAuthState{ID: 1, RoleID: 0, Status: 0, PasswordChangedAt: 0}},
|
||||
{name: "role changed", state: &auth.UserAuthState{ID: 1, RoleID: 1, Status: 1, PasswordChangedAt: 0}},
|
||||
{name: "password changed", state: &auth.UserAuthState{ID: 1, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs + 1}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := NewServer(nil, secret)
|
||||
server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) {
|
||||
return tt.state, nil
|
||||
})
|
||||
|
||||
if ok := server.validateAdminSession(1, claims); ok {
|
||||
t.Fatalf("expected session validation to fail for %s state", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdminSessionRejectsExpiredToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
claims.Exp = 1
|
||||
|
||||
server := NewServer(nil, secret)
|
||||
server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: 0}, nil
|
||||
})
|
||||
|
||||
if ok := server.validateAdminSession(1, claims); ok {
|
||||
t.Fatal("expected expired token to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPAllowsMonitorTokenWithPermission(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
r, err := repo.Open(t.TempDir() + "/monitor.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
if err := r.InsertMonitorPermission(2, 123); err != nil {
|
||||
t.Fatalf("insert permission: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(r, secret)
|
||||
server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(server)
|
||||
defer ts.Close()
|
||||
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(
|
||||
"ws"+strings.TrimPrefix(ts.URL, "http")+"/system-info?type=0&secret="+url.QueryEscape(token),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
t.Fatalf("dial websocket error = %v, status=%d", err, resp.StatusCode)
|
||||
}
|
||||
t.Fatalf("dial websocket error = %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestConnWrapSerializesConcurrentWrites(t *testing.T) {
|
||||
serverConn, clientConn := websocketTestPipe(t)
|
||||
defer serverConn.Close()
|
||||
defer clientConn.Close()
|
||||
|
||||
cw := &connWrap{conn: serverConn}
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
for i := 0; i < 64; i++ {
|
||||
if _, _, err := clientConn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := writeWSMessage(cw, websocket.TextMessage, []byte("x")); err != nil {
|
||||
t.Errorf("writeWSMessage() error = %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
_ = clientConn.Close()
|
||||
<-readerDone
|
||||
}
|
||||
|
||||
func websocketTestPipe(t *testing.T) (*websocket.Conn, *websocket.Conn) {
|
||||
t.Helper()
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
serverConnCh := make(chan *websocket.Conn, 1)
|
||||
serverErrCh := make(chan error, 1)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
serverConnCh <- conn
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial websocket: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-serverErrCh:
|
||||
t.Fatalf("upgrade websocket: %v", err)
|
||||
case serverConn := <-serverConnCh:
|
||||
return serverConn, clientConn
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
)
|
||||
|
||||
func TestJWTMiddlewareContracts(t *testing.T) {
|
||||
@@ -18,7 +20,9 @@ func TestJWTMiddlewareContracts(t *testing.T) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
})
|
||||
|
||||
wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret})(next)
|
||||
wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret, GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: 0}, nil
|
||||
}})(next)
|
||||
|
||||
t.Run("login path is excluded", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
|
||||
@@ -59,6 +63,9 @@ func TestJWTMiddlewareContracts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret, GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
|
||||
}})(next)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
@@ -67,6 +74,83 @@ func TestJWTMiddlewareContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginTokenValidatesThroughRouter(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
seedLegacyUser(t, r, 9110, "router-login-user", "router-login-pass")
|
||||
|
||||
body := bytes.NewBufferString(`{"username":"router-login-user","password":"router-login-pass","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode login response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected login code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected login data map, got %T", out.Data)
|
||||
}
|
||||
token, _ := data["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatal("expected login token")
|
||||
}
|
||||
|
||||
checkReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/tunnel", nil)
|
||||
checkReq.Header.Set("Authorization", token)
|
||||
checkResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(checkResp, checkReq)
|
||||
assertCode(t, checkResp, 0)
|
||||
}
|
||||
|
||||
func TestLegacyPasswordMigratesOnLogin(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
legacyChangedAt := seedLegacyUser(t, r, 9101, "legacy-login-user", "legacy-login-pass")
|
||||
|
||||
body := bytes.NewBufferString(`{"username":"legacy-login-user","password":"legacy-login-pass","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCode(t, resp, 0)
|
||||
assertUserPasswordIsBcrypt(t, r, "legacy-login-user", "legacy-login-pass")
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "legacy-login-user"); changedAt <= legacyChangedAt {
|
||||
t.Fatalf("expected password_changed_at to advance on login migration, got %d <= %d", changedAt, legacyChangedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledLegacyPasswordIsRejectedWithoutMigration(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
legacyChangedAt := seedLegacyUserWithStatus(t, r, 9105, "disabled-legacy-user", "disabled-legacy-pass", 0)
|
||||
|
||||
body := bytes.NewBufferString(`{"username":"disabled-legacy-user","password":"disabled-legacy-pass","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCodeMsg(t, resp, -1, "账号被停用")
|
||||
|
||||
user, err := r.GetUserByUsername("disabled-legacy-user")
|
||||
if err != nil {
|
||||
t.Fatalf("get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatal("expected disabled user to exist")
|
||||
}
|
||||
if ok, migrated := security.VerifyPassword(user.Pwd, "disabled-legacy-pass"); !ok || !migrated {
|
||||
t.Fatalf("expected disabled user to remain legacy MD5, got (%v,%v) with hash %q", ok, migrated, user.Pwd)
|
||||
}
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "disabled-legacy-user"); changedAt != legacyChangedAt {
|
||||
t.Fatalf("expected password_changed_at to remain unchanged, got %d want %d", changedAt, legacyChangedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/repo"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -128,6 +129,52 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublicConfigGetAndAuthConfigContract(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"app_name": "FLVX Public",
|
||||
"app_logo": "logo",
|
||||
"app_favicon": "favicon",
|
||||
"app_bg_image": "bg",
|
||||
"cloudflare_site_key": "site-key",
|
||||
"cloudflare_secret_key": "secret-key",
|
||||
"jwt_secret": "jwt-secret",
|
||||
} {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, name, value, time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
publicReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||
publicReq.Header.Set("Content-Type", "application/json")
|
||||
publicResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(publicResp, publicReq)
|
||||
assertCode(t, publicResp, 0)
|
||||
|
||||
secretReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
secretReq.Header.Set("Content-Type", "application/json")
|
||||
secretResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(secretResp, secretReq)
|
||||
assertCodeMsg(t, secretResp, 403, "禁止访问敏感配置")
|
||||
|
||||
configReq := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"cloudflare_site_key"}`))
|
||||
configReq.Header.Set("Content-Type", "application/json")
|
||||
configResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(configResp, configReq)
|
||||
assertCode(t, configResp, 0)
|
||||
|
||||
configSecretReq := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
configSecretReq.Header.Set("Content-Type", "application/json")
|
||||
configSecretResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(configSecretResp, configSecretReq)
|
||||
assertCodeMsg(t, configSecretResp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
@@ -209,6 +256,122 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLegacyPasswordMigratesOnSubStore(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
legacyChangedAt := seedLegacyUser(t, r, 9102, "legacy-substore-user", "legacy-substore-pass")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=legacy-substore-user&pwd=legacy-substore-pass", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
expected := "upload=0; download=0; total=107373108658176; expire=2727251700"
|
||||
if string(body) != expected {
|
||||
t.Fatalf("expected body %q, got %q", expected, string(body))
|
||||
}
|
||||
assertUserPasswordIsBcrypt(t, r, "legacy-substore-user", "legacy-substore-pass")
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "legacy-substore-user"); changedAt <= legacyChangedAt {
|
||||
t.Fatalf("expected password_changed_at to advance on sub-store migration, got %d <= %d", changedAt, legacyChangedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledLegacyPasswordIsRejectedOnSubStoreWithoutMigration(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
legacyChangedAt := seedLegacyUserWithStatus(t, r, 9106, "disabled-substore-user", "disabled-substore-pass", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=disabled-substore-user&pwd=disabled-substore-pass", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCodeMsg(t, resp, -1, "账号被停用")
|
||||
|
||||
user, err := r.GetUserByUsername("disabled-substore-user")
|
||||
if err != nil {
|
||||
t.Fatalf("get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatal("expected disabled user to exist")
|
||||
}
|
||||
if ok, migrated := security.VerifyPassword(user.Pwd, "disabled-substore-pass"); !ok || !migrated {
|
||||
t.Fatalf("expected disabled user to remain legacy MD5, got (%v,%v) with hash %q", ok, migrated, user.Pwd)
|
||||
}
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "disabled-substore-user"); changedAt != legacyChangedAt {
|
||||
t.Fatalf("expected password_changed_at to remain unchanged, got %d want %d", changedAt, legacyChangedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreateStoresStrongHash(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
startedAt := time.Now().UnixMilli()
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, "contract-jwt-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"user":"created-user","pwd":"created-pass"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/create", body)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCode(t, resp, 0)
|
||||
assertUserPasswordIsBcrypt(t, r, "created-user", "created-pass")
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "created-user"); changedAt < startedAt {
|
||||
t.Fatalf("expected password_changed_at to be set on create, got %d < %d", changedAt, startedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpdateStoresStrongHash(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
seedLegacyUser(t, r, 9103, "legacy-update-user", "legacy-update-pass")
|
||||
startedAt := time.Now().UnixMilli()
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, "contract-jwt-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"id":9103,"user":"updated-user","pwd":"updated-pass","flow":99999,"num":99999,"expTime":2727251700000,"flowResetTime":1,"status":1,"maxConn":0}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/update", body)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCode(t, resp, 0)
|
||||
assertUserPasswordIsBcrypt(t, r, "updated-user", "updated-pass")
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "updated-user"); changedAt < startedAt {
|
||||
t.Fatalf("expected password_changed_at to be updated on user update, got %d < %d", changedAt, startedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePasswordStoresStrongHash(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
seedLegacyUser(t, r, 9104, "legacy-self-user", "legacy-self-pass")
|
||||
startedAt := time.Now().UnixMilli()
|
||||
token, err := auth.GenerateToken(9104, "legacy-self-user", 1, "contract-jwt-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"newUsername":"self-updated-user","currentPassword":"legacy-self-pass","newPassword":"self-updated-pass","confirmPassword":"self-updated-pass"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/updatePassword", body)
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCode(t, resp, 0)
|
||||
assertUserPasswordIsBcrypt(t, r, "self-updated-user", "self-updated-pass")
|
||||
if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "self-updated-user"); changedAt < startedAt {
|
||||
t.Fatalf("expected password_changed_at to be updated on password change, got %d < %d", changedAt, startedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
@@ -232,6 +395,7 @@ func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
seedContractUser(t, r, 2, "normal_user", 1, 1)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
@@ -559,6 +723,71 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackupConfigFilteringContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
configs := map[string]string{
|
||||
"app_name": "contract-before",
|
||||
"cloudflare_site_key": "site-key-before",
|
||||
"jwt_secret": "jwt-before",
|
||||
"license_key": "license-before",
|
||||
"cloudflare_secret_key": "cloudflare-before",
|
||||
}
|
||||
for name, value := range configs {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, name, value, time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} {
|
||||
if _, ok := payload.Configs[key]; ok {
|
||||
t.Fatalf("expected %s to be omitted from exported configs: %+v", key, payload.Configs)
|
||||
}
|
||||
}
|
||||
if payload.Configs["app_name"] != "contract-before" {
|
||||
t.Fatalf("expected public config to be exported, got %+v", payload.Configs)
|
||||
}
|
||||
|
||||
payload.Configs["app_name"] = "contract-after"
|
||||
payload.Configs["jwt_secret"] = "jwt-after"
|
||||
payload.Configs["license_key"] = "license-after"
|
||||
payload.Configs["cloudflare_secret_key"] = "cloudflare-after"
|
||||
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal import payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode import response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
assertConfigValue(t, r, "app_name", "contract-after")
|
||||
assertConfigValue(t, r, "jwt_secret", "jwt-before")
|
||||
assertConfigValue(t, r, "license_key", "license-before")
|
||||
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
}
|
||||
|
||||
type backupExportPayload struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
@@ -619,6 +848,66 @@ func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Re
|
||||
return httpserver.NewRouter(h, jwtSecret), r
|
||||
}
|
||||
|
||||
func assertConfigValue(t *testing.T, r *repo.Repository, name, want string) {
|
||||
t.Helper()
|
||||
cfg, err := r.GetConfigByName(name)
|
||||
if err != nil {
|
||||
t.Fatalf("get config %s: %v", name, err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != want {
|
||||
t.Fatalf("expected config %s=%q, got %+v", name, want, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func seedLegacyUser(t *testing.T, r *repo.Repository, id int64, username, password string) int64 {
|
||||
return seedLegacyUserWithStatus(t, r, id, username, password, 1)
|
||||
}
|
||||
|
||||
func seedContractUser(t *testing.T, r *repo.Repository, id int64, username string, roleID, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
passwordChangedAt := now - 10_000
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status, password_changed_at)
|
||||
VALUES(?, ?, ?, ?, 2727251700000, 99999, 0, 0, 1, 99999, 0, ?, ?, ?, ?)
|
||||
`, id, username, security.MD5("contract-pass"), roleID, now, now, status, passwordChangedAt).Error; err != nil {
|
||||
t.Fatalf("seed contract user %s: %v", username, err)
|
||||
}
|
||||
return passwordChangedAt
|
||||
}
|
||||
|
||||
func seedLegacyUserWithStatus(t *testing.T, r *repo.Repository, id int64, username, password string, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
legacyChangedAt := now - 10_000
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status, password_changed_at)
|
||||
VALUES(?, ?, ?, 1, 2727251700000, 99999, 0, 0, 1, 99999, 0, ?, ?, ?, ?)
|
||||
`, id, username, security.MD5(password), now, now, status, legacyChangedAt).Error; err != nil {
|
||||
t.Fatalf("seed legacy user %s: %v", username, err)
|
||||
}
|
||||
return legacyChangedAt
|
||||
}
|
||||
|
||||
func mustQueryPasswordChangedAtByUsername(t *testing.T, r *repo.Repository, username string) int64 {
|
||||
t.Helper()
|
||||
return mustQueryInt64(t, r, `SELECT password_changed_at FROM user WHERE user = ?`, username)
|
||||
}
|
||||
|
||||
func assertUserPasswordIsBcrypt(t *testing.T, r *repo.Repository, username, password string) {
|
||||
t.Helper()
|
||||
user, err := r.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatalf("get user %s: %v", username, err)
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatalf("expected user %s to exist", username)
|
||||
}
|
||||
if ok, migrated := security.VerifyPassword(user.Pwd, password); !ok || migrated {
|
||||
t.Fatalf("expected bcrypt password for %s, got %q", username, user.Pwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db")
|
||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
func TestNodeMetricsEndpoints(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
seedContractUser(t, repo, 2, "normal_user", 1, 1)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
@@ -1302,6 +1303,7 @@ func TestMonitoringAuthRequired(t *testing.T) {
|
||||
func TestMonitorAccessEndpoint(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
seedContractUser(t, repo, 2, "normal_user", 1, 1)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
@@ -1357,6 +1359,7 @@ func TestMonitorAccessEndpoint(t *testing.T) {
|
||||
func TestMonitoringPermissionRequired(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
seedContractUser(t, repo, 2, "normal_user", 1, 1)
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
|
||||
func TestStorageSummaryRequiresAdminAndReturnsSize(t *testing.T) {
|
||||
secret := "storage-contract-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
seedContractUser(t, r, 2, "normal_user", 1, 1)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
|
||||
+8
-12
@@ -22,6 +22,7 @@ require (
|
||||
github.com/coreos/go-iptables v0.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/cors v1.7.2 // indirect
|
||||
@@ -37,13 +38,11 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect
|
||||
@@ -61,13 +60,11 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.6 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/transport/v2 v2.0.2 // indirect
|
||||
github.com/pion/udp/v2 v2.0.1 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.11 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
@@ -75,9 +72,9 @@ require (
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.49.1 // indirect
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
||||
github.com/rs/xid v1.3.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
@@ -86,7 +83,7 @@ require (
|
||||
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/sirupsen/logrus v1.8.3 // indirect
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
@@ -110,7 +107,6 @@ require (
|
||||
github.com/yl2chen/cidranger v1.0.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zalando/go-keyring v0.2.4 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
|
||||
+21
-51
@@ -33,12 +33,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -79,8 +79,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
@@ -114,8 +112,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 h1:NaJi58bCZZh0jjPw78EqDZekPEfhlzYE01C5R+zh1tE=
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866/go.mod h1:riddUzxTSBpJXk3qBHtYr4qOhFhT6k/1c0E3qkQjQpA=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -163,22 +159,18 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/dtls/v2 v2.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
|
||||
github.com/pion/dtls/v2 v2.2.6/go.mod h1:t8fWJCIquY5rlQZwA2yWxUS1+OCrAdXrhVKXB5oD/wY=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/transport/v2 v2.0.2 h1:St+8o+1PEzPT51O9bv+tH/KYYLMNR5Vwm5Z3Qkjsywg=
|
||||
github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0=
|
||||
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
|
||||
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
|
||||
github.com/pion/dtls/v3 v3.0.11 h1:zqn8YhoAU7d9whsWLhNiQlbB8QdpJj8XQVSc5ImUons=
|
||||
github.com/pion/dtls/v3 v3.0.11/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -197,12 +189,12 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0=
|
||||
github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -224,8 +216,8 @@ github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU=
|
||||
github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -252,8 +244,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/templexxx/cpu v0.1.0 h1:wVM+WIJP2nYaxVxqgHPD4wGA2aJ9rvrQRV8CvFzNb40=
|
||||
@@ -290,7 +283,6 @@ github.com/xtaci/tcpraw v1.2.25 h1:VDlqo0op17JeXBM6e2G9ocCNLOJcw9mZbobMbJjo0vk=
|
||||
github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1GgJQbLk=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
@@ -307,8 +299,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@@ -320,8 +312,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -332,7 +322,6 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -343,17 +332,12 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -365,13 +349,9 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -379,17 +359,10 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
@@ -399,12 +372,9 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
md "github.com/go-gost/core/metadata"
|
||||
xdtls "github.com/go-gost/x/internal/util/dtls"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/pion/dtls/v2"
|
||||
"github.com/pion/dtls/v3"
|
||||
dtlsnet "github.com/pion/dtls/v3/pkg/net"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -66,9 +67,13 @@ func (d *dtlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
MTU: d.md.mtu,
|
||||
}
|
||||
|
||||
c, err := dtls.ClientWithContext(ctx, conn, &config)
|
||||
c, err := dtls.Client(dtlsnet.PacketConnFromConn(conn), conn.RemoteAddr(), &config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.HandshakeContext(ctx); err != nil {
|
||||
_ = c.Close()
|
||||
return nil, err
|
||||
}
|
||||
return xdtls.Conn(c, d.md.bufferSize), nil
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func (d *http3Dialer) Dial(ctx context.Context, addr string, opts ...dialer.Dial
|
||||
// Timeout: 60 * time.Second,
|
||||
Transport: &http3.Transport{
|
||||
TLSClientConfig: d.options.TLSConfig,
|
||||
Dial: func(ctx context.Context, adr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
|
||||
Dial: func(ctx context.Context, adr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
||||
// d.options.Logger.Infof("dial: %s/%s, %s", addr, network, host)
|
||||
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
|
||||
@@ -74,7 +74,7 @@ func (d *wtDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOpt
|
||||
header: d.md.header,
|
||||
dialer: &wt.Dialer{
|
||||
TLSClientConfig: d.options.TLSConfig,
|
||||
DialAddr: func(ctx context.Context, adr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
|
||||
DialAddr: func(ctx context.Context, adr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
||||
// d.options.Logger.Infof("dial: %s, %s, %s", addr, adr, host)
|
||||
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
@@ -97,8 +97,9 @@ func (d *wtDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOpt
|
||||
quic.Version1,
|
||||
},
|
||||
*/
|
||||
MaxIncomingStreams: int64(d.md.maxStreams),
|
||||
EnableDatagrams: true,
|
||||
MaxIncomingStreams: int64(d.md.maxStreams),
|
||||
EnableDatagrams: true,
|
||||
EnableStreamResetPartialDelivery: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
type quicSession struct {
|
||||
session quic.EarlyConnection
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
@@ -28,7 +28,7 @@ func (session *quicSession) Close() error {
|
||||
}
|
||||
|
||||
type quicConn struct {
|
||||
quic.Stream
|
||||
*quic.Stream
|
||||
laddr net.Addr
|
||||
raddr net.Addr
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
type quicSession struct {
|
||||
session quic.EarlyConnection
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
@@ -28,7 +28,7 @@ func (session *quicSession) Close() error {
|
||||
}
|
||||
|
||||
type quicConn struct {
|
||||
quic.Stream
|
||||
*quic.Stream
|
||||
laddr net.Addr
|
||||
raddr net.Addr
|
||||
}
|
||||
|
||||
+11
-14
@@ -23,18 +23,18 @@ require (
|
||||
github.com/miekg/dns v1.1.61
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pion/dtls/v2 v2.2.6
|
||||
github.com/pion/dtls/v3 v3.0.11
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.49.1
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66
|
||||
github.com/quic-go/quic-go v0.59.0
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5
|
||||
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/sirupsen/logrus v1.8.3
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/vishvananda/netlink v1.1.0
|
||||
github.com/vishvananda/netns v0.0.4
|
||||
github.com/vulcand/predicate v1.2.0
|
||||
@@ -48,7 +48,7 @@ require (
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/time v0.10.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
@@ -69,6 +69,7 @@ require (
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
@@ -76,11 +77,9 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||
@@ -94,18 +93,17 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||
github.com/onsi/gomega v1.34.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/transport/v2 v2.0.2 // indirect
|
||||
github.com/pion/udp/v2 v2.0.1 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
@@ -123,7 +121,6 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
|
||||
+23
-53
@@ -33,12 +33,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -79,8 +79,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
@@ -114,8 +112,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -161,22 +157,18 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/dtls/v2 v2.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
|
||||
github.com/pion/dtls/v2 v2.2.6/go.mod h1:t8fWJCIquY5rlQZwA2yWxUS1+OCrAdXrhVKXB5oD/wY=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/transport/v2 v2.0.2 h1:St+8o+1PEzPT51O9bv+tH/KYYLMNR5Vwm5Z3Qkjsywg=
|
||||
github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0=
|
||||
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
|
||||
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
|
||||
github.com/pion/dtls/v3 v3.0.11 h1:zqn8YhoAU7d9whsWLhNiQlbB8QdpJj8XQVSc5ImUons=
|
||||
github.com/pion/dtls/v3 v3.0.11/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -195,12 +187,12 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0=
|
||||
github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -222,8 +214,8 @@ github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU=
|
||||
github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -250,8 +242,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/templexxx/cpu v0.1.0 h1:wVM+WIJP2nYaxVxqgHPD4wGA2aJ9rvrQRV8CvFzNb40=
|
||||
@@ -285,7 +278,6 @@ github.com/xtaci/tcpraw v1.2.25 h1:VDlqo0op17JeXBM6e2G9ocCNLOJcw9mZbobMbJjo0vk=
|
||||
github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1GgJQbLk=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
@@ -302,8 +294,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@@ -315,8 +307,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -327,7 +317,6 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -338,17 +327,12 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -358,12 +342,8 @@ golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -371,32 +351,22 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
type conn struct {
|
||||
session *wt.Session
|
||||
stream wt.Stream
|
||||
stream *wt.Stream
|
||||
}
|
||||
|
||||
func Conn(session *wt.Session, stream wt.Stream) net.Conn {
|
||||
func Conn(session *wt.Session, stream *wt.Stream) net.Conn {
|
||||
return &conn{
|
||||
session: session,
|
||||
stream: stream,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
metrics "github.com/go-gost/x/metrics/wrapper"
|
||||
stats "github.com/go-gost/x/observer/stats/wrapper"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/pion/dtls/v2"
|
||||
"github.com/pion/dtls/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -65,20 +65,20 @@ func (l *dtlsListener) Init(md md.Metadata) (err error) {
|
||||
config := dtls.Config{
|
||||
Certificates: tlsCfg.Certificates,
|
||||
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
|
||||
// Create timeout context for accepted connection.
|
||||
ConnectContextMaker: func() (context.Context, func()) {
|
||||
return context.WithTimeout(context.Background(), 30*time.Second)
|
||||
},
|
||||
ClientCAs: tlsCfg.ClientCAs,
|
||||
ClientAuth: dtls.ClientAuthType(tlsCfg.ClientAuth),
|
||||
FlightInterval: l.md.flightInterval,
|
||||
MTU: l.md.mtu,
|
||||
ClientCAs: tlsCfg.ClientCAs,
|
||||
ClientAuth: dtls.ClientAuthType(tlsCfg.ClientAuth),
|
||||
FlightInterval: l.md.flightInterval,
|
||||
MTU: l.md.mtu,
|
||||
}
|
||||
|
||||
ln, err := dtls.Listen(network, laddr, &config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ln = &handshakeListener{
|
||||
Listener: ln,
|
||||
timeout: 30 * time.Second,
|
||||
}
|
||||
ln = proxyproto.WrapListener(l.options.ProxyProtocol, ln, 10*time.Second)
|
||||
ln = metrics.WrapListener(l.options.Service, ln)
|
||||
ln = stats.WrapListener(ln, l.options.Stats)
|
||||
@@ -117,3 +117,28 @@ func (l *dtlsListener) Addr() net.Addr {
|
||||
func (l *dtlsListener) Close() error {
|
||||
return l.ln.Close()
|
||||
}
|
||||
|
||||
type handshakeListener struct {
|
||||
net.Listener
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (l *handshakeListener) Accept() (net.Conn, error) {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtlsConn, ok := c.(*dtls.Conn)
|
||||
if !ok {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), l.timeout)
|
||||
err = dtlsConn.HandshakeContext(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = c.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func (l *wtListener) Init(md md.Metadata) (err error) {
|
||||
Allow0RTT: true,
|
||||
}
|
||||
l.srv = &wt.Server{
|
||||
H3: http3.Server{
|
||||
H3: &http3.Server{
|
||||
Addr: l.options.Addr,
|
||||
TLSConfig: l.options.TLSConfig,
|
||||
QUICConfig: quicCfg,
|
||||
@@ -182,7 +182,7 @@ func (l *wtListener) mux(s *wt.Session, log logger.Logger) (err error) {
|
||||
}()
|
||||
|
||||
for {
|
||||
var stream wt.Stream
|
||||
var stream *wt.Stream
|
||||
stream, err = s.AcceptStream(s.Context())
|
||||
if err != nil {
|
||||
log.Errorf("accept stream: %v", err)
|
||||
@@ -193,7 +193,7 @@ func (l *wtListener) mux(s *wt.Session, log logger.Logger) (err error) {
|
||||
case l.cqueue <- wt_util.Conn(s, stream):
|
||||
default:
|
||||
stream.Close()
|
||||
l.logger.Warnf("connection queue is full, stream %v discarded", stream.StreamID())
|
||||
l.logger.Warn("connection queue is full, stream discarded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
type quicConn struct {
|
||||
quic.Stream
|
||||
*quic.Stream
|
||||
laddr net.Addr
|
||||
raddr net.Addr
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (l *icmpListener) listenLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *icmpListener) mux(ctx context.Context, session quic.EarlyConnection) {
|
||||
func (l *icmpListener) mux(ctx context.Context, session *quic.Conn) {
|
||||
defer session.CloseWithError(0, "closed")
|
||||
|
||||
for {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
type quicConn struct {
|
||||
quic.Stream
|
||||
*quic.Stream
|
||||
laddr net.Addr
|
||||
raddr net.Addr
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func (l *quicListener) listenLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *quicListener) mux(ctx context.Context, session quic.Connection) {
|
||||
func (l *quicListener) mux(ctx context.Context, session *quic.Conn) {
|
||||
defer session.CloseWithError(0, "closed")
|
||||
|
||||
for {
|
||||
|
||||
@@ -158,6 +158,9 @@ type WebSocketReporter struct {
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
http int
|
||||
tls int
|
||||
socks int
|
||||
preferredWSScheme string
|
||||
conn *websocket.Conn
|
||||
curBackoff time.Duration // 当前重连退避间隔
|
||||
@@ -296,9 +299,9 @@ func (w *WebSocketReporter) connect() error {
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
|
||||
var cfg LocalConfig
|
||||
cfg := LocalConfig{Http: w.http, Tls: w.tls, Socks: w.socks}
|
||||
if b, err := os.ReadFile("config.json"); err == nil {
|
||||
json.Unmarshal(b, &cfg)
|
||||
_ = json.Unmarshal(b, &cfg)
|
||||
}
|
||||
|
||||
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
|
||||
@@ -1369,7 +1372,7 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
// 执行重启脚本
|
||||
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
|
||||
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
|
||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath)
|
||||
script := buildAgentRestartScript(tmpPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
@@ -1403,6 +1406,14 @@ func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildAgentRestartScript(tmpPath, binaryPath string) string {
|
||||
return fmt.Sprintf(
|
||||
"sleep 1 && systemctl stop flux_agent && legacy_service='' && for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f \"$service_file\" ] && grep -Fq \"WorkingDirectory=/etc/gost\" \"$service_file\" && (grep -Fq \"ExecStart=/etc/gost/gost\" \"$service_file\" || (grep -Fq \"ExecStart=/usr/local/bin/gost\" \"$service_file\" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service=\"$service_file\"; break; fi; done && if [ -n \"$legacy_service\" ]; then (systemctl stop gost 2>/dev/null || true) && (systemctl disable gost 2>/dev/null || true) && rm -f /usr/local/bin/gost /etc/gost/gost \"$legacy_service\" && (systemctl daemon-reload 2>/dev/null || true); fi && mv %s %s && systemctl start flux_agent",
|
||||
tmpPath,
|
||||
binaryPath,
|
||||
)
|
||||
}
|
||||
|
||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||
path := "config.json"
|
||||
@@ -1650,13 +1661,16 @@ func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls
|
||||
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
|
||||
fullURL := candidates[0]
|
||||
|
||||
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||
fmt.Printf("🔗 WebSocket连接URL: %s\n", sanitizeWebSocketURL(fullURL))
|
||||
|
||||
reporter := NewWebSocketReporter(fullURL, secret)
|
||||
// 保存 addr, secret, version 供重连时使用
|
||||
// 保存 addr, secret, version 和协议能力供重连时使用
|
||||
reporter.addr = addr
|
||||
reporter.secret = secret
|
||||
reporter.version = version
|
||||
reporter.http = http
|
||||
reporter.tls = tls
|
||||
reporter.socks = socks
|
||||
reporter.Start()
|
||||
return reporter
|
||||
}
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
orig := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout pipe: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
defer func() {
|
||||
os.Stdout = orig
|
||||
_ = w.Close()
|
||||
_ = r.Close()
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
_ = w.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf("read stdout: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
|
||||
|
||||
@@ -133,3 +163,103 @@ func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
|
||||
t.Fatalf("expected response body in message, got %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) {
|
||||
script := buildAgentRestartScript("/tmp/flux_agent.new", "/etc/flux_agent/flux_agent")
|
||||
|
||||
if !strings.Contains(script, "systemctl stop flux_agent") {
|
||||
t.Fatalf("expected script to stop flux_agent, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "mv /tmp/flux_agent.new /etc/flux_agent/flux_agent") {
|
||||
t.Fatalf("expected script to replace the flux_agent binary, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "systemctl stop gost") {
|
||||
t.Fatalf("expected script to stop the legacy gost service, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "systemctl disable gost") {
|
||||
t.Fatalf("expected script to disable the legacy gost service, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "rm -f /usr/local/bin/gost") {
|
||||
t.Fatalf("expected script to remove the legacy gost binary, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "WorkingDirectory=/etc/gost") {
|
||||
t.Fatalf("expected script to scope cleanup to the legacy FLVX gost service definition, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "systemctl start flux_agent") {
|
||||
t.Fatalf("expected script to restart flux_agent, got %s", script)
|
||||
}
|
||||
if strings.Contains(script, "systemctl stop flux_agent && systemctl stop gost 2>/dev/null || true") {
|
||||
t.Fatalf("expected legacy gost cleanup fallback to be scoped, got %s", script)
|
||||
}
|
||||
if runtime.GOARCH == "" {
|
||||
t.Fatalf("unexpected empty runtime arch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartWebSocketReporterWithConfigPreservesProtocolDefaultsWithoutConfigFile(t *testing.T) {
|
||||
origDial := wsDial
|
||||
defer func() { wsDial = origDial }()
|
||||
|
||||
origWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get working directory: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origWD)
|
||||
})
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("change working directory: %v", err)
|
||||
}
|
||||
|
||||
urls := make(chan string, 1)
|
||||
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
select {
|
||||
case urls <- rawURL:
|
||||
default:
|
||||
}
|
||||
return nil, nil, errors.New("dial failed")
|
||||
}
|
||||
|
||||
reporter := StartWebSocketReporterWithConfig("panel.example.com:443", "abc", 1, 0, 1, "2.0.2")
|
||||
defer reporter.Stop()
|
||||
|
||||
select {
|
||||
case rawURL := <-urls:
|
||||
if !strings.Contains(rawURL, "http=1&tls=0&socks=1") {
|
||||
t.Fatalf("expected reconnect URL to preserve startup protocol values, got %s", rawURL)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for websocket dial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartWebSocketReporterWithConfigLogsSanitizedURL(t *testing.T) {
|
||||
origDial := wsDial
|
||||
defer func() { wsDial = origDial }()
|
||||
|
||||
ready := make(chan struct{}, 1)
|
||||
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil, nil, errors.New("dial failed")
|
||||
}
|
||||
|
||||
output := captureStdout(t, func() {
|
||||
reporter := StartWebSocketReporterWithConfig("panel.example.com:443", "abc123", 1, 0, 1, "2.0.2")
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for websocket dial")
|
||||
}
|
||||
reporter.Stop()
|
||||
})
|
||||
|
||||
if strings.Contains(output, "secret=abc123") {
|
||||
t.Fatalf("expected logged websocket URL to mask the node secret, got %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "secret=%2A%2A%2A") {
|
||||
t.Fatalf("expected logged websocket URL to include masked secret, got %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
+79
-11
@@ -24,6 +24,11 @@ get_architecture() {
|
||||
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
LEGACY_GOST_BINARY="/usr/local/bin/gost"
|
||||
LEGACY_GOST_CONFIG_DIR="/etc/gost"
|
||||
LEGACY_GOST_SERVICE_FILE_ETC="/etc/systemd/system/gost.service"
|
||||
LEGACY_GOST_SERVICE_FILE_LIB="/lib/systemd/system/gost.service"
|
||||
LEGACY_GOST_SERVICE_FILE_USR_LIB="/usr/lib/systemd/system/gost.service"
|
||||
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
@@ -234,6 +239,69 @@ check_and_install_tcpkill() {
|
||||
return 0
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local value="$1"
|
||||
value=${value//\\/\\\\}
|
||||
value=${value//\"/\\\"}
|
||||
value=${value//$'\n'/\\n}
|
||||
value=${value//$'\r'/\\r}
|
||||
value=${value//$'\t'/\\t}
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
write_flux_agent_config() {
|
||||
local path="$1"
|
||||
printf '{\n "addr": "%s",\n "secret": "%s"\n}\n' \
|
||||
"$(json_escape "$SERVER_ADDR")" \
|
||||
"$(json_escape "$SECRET")" > "$path"
|
||||
}
|
||||
|
||||
cleanup_legacy_gost_installation() {
|
||||
local matched_service_files=()
|
||||
local service_file=""
|
||||
local removed_service_file="0"
|
||||
|
||||
for service_file in "$LEGACY_GOST_SERVICE_FILE_ETC" "$LEGACY_GOST_SERVICE_FILE_LIB" "$LEGACY_GOST_SERVICE_FILE_USR_LIB"; do
|
||||
if [[ ! -f "$service_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! grep -Fq "WorkingDirectory=$LEGACY_GOST_CONFIG_DIR" "$service_file"; then
|
||||
continue
|
||||
fi
|
||||
if grep -Fq "ExecStart=$LEGACY_GOST_CONFIG_DIR/gost" "$service_file" || \
|
||||
(grep -Fq "ExecStart=$LEGACY_GOST_BINARY" "$service_file" && [[ -f "$LEGACY_GOST_CONFIG_DIR/config.json" && -f "$LEGACY_GOST_CONFIG_DIR/gost.json" ]]); then
|
||||
matched_service_files+=("$service_file")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#matched_service_files[@]} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
|
||||
systemctl stop gost 2>/dev/null || true
|
||||
systemctl disable gost 2>/dev/null || true
|
||||
fi
|
||||
|
||||
for service_file in "${matched_service_files[@]}"; do
|
||||
if [[ -f "$service_file" ]]; then
|
||||
rm -f "$service_file"
|
||||
removed_service_file="1"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f "$LEGACY_GOST_BINARY" ]]; then
|
||||
rm -f "$LEGACY_GOST_BINARY"
|
||||
fi
|
||||
if [[ -f "$LEGACY_GOST_CONFIG_DIR/gost" ]]; then
|
||||
rm -f "$LEGACY_GOST_CONFIG_DIR/gost"
|
||||
fi
|
||||
|
||||
if [[ "$removed_service_file" == "1" ]]; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# 获取用户输入的配置参数
|
||||
get_config_params() {
|
||||
@@ -279,6 +347,8 @@ install_flux_agent() {
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
local tmp_binary="$INSTALL_DIR/flux_agent.new"
|
||||
|
||||
# 停止并禁用已有服务
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🔍 检测到已存在的flux_agent服务"
|
||||
@@ -286,16 +356,17 @@ install_flux_agent() {
|
||||
systemctl disable flux_agent 2>/dev/null && echo "🚫 禁用自启"
|
||||
fi
|
||||
|
||||
# 删除旧文件
|
||||
[[ -f "$INSTALL_DIR/flux_agent" ]] && echo "🧹 删除旧文件 flux_agent" && rm -f "$INSTALL_DIR/flux_agent"
|
||||
|
||||
# 下载 flux_agent
|
||||
echo "⬇️ 下载 flux_agent 中..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent"
|
||||
if [[ ! -f "$INSTALL_DIR/flux_agent" || ! -s "$INSTALL_DIR/flux_agent" ]]; then
|
||||
rm -f "$tmp_binary"
|
||||
curl -L "$DOWNLOAD_URL" -o "$tmp_binary"
|
||||
if [[ ! -f "$tmp_binary" || ! -s "$tmp_binary" ]]; then
|
||||
rm -f "$tmp_binary"
|
||||
echo "❌ 下载失败,请检查网络或下载链接。"
|
||||
exit 1
|
||||
fi
|
||||
cleanup_legacy_gost_installation
|
||||
mv "$tmp_binary" "$INSTALL_DIR/flux_agent"
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
echo "✅ 下载完成"
|
||||
|
||||
@@ -305,12 +376,7 @@ install_flux_agent() {
|
||||
# 写入 config.json (安装时总是创建新的)
|
||||
CONFIG_FILE="$INSTALL_DIR/config.json"
|
||||
echo "📄 创建新配置: config.json"
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
{
|
||||
"addr": "$SERVER_ADDR",
|
||||
"secret": "$SECRET"
|
||||
}
|
||||
EOF
|
||||
write_flux_agent_config "$CONFIG_FILE"
|
||||
|
||||
# 写入 gost.json
|
||||
GOST_CONFIG="$INSTALL_DIR/gost.json"
|
||||
@@ -380,11 +446,13 @@ update_flux_agent() {
|
||||
|
||||
# 先下载新版本
|
||||
echo "⬇️ 下载最新版本..."
|
||||
rm -f "$INSTALL_DIR/flux_agent.new"
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent.new"
|
||||
if [[ ! -f "$INSTALL_DIR/flux_agent.new" || ! -s "$INSTALL_DIR/flux_agent.new" ]]; then
|
||||
echo "❌ 下载失败。"
|
||||
return 1
|
||||
fi
|
||||
cleanup_legacy_gost_installation
|
||||
|
||||
# 停止服务
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
|
||||
@@ -98,6 +98,7 @@ EOF
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
|
||||
local ask_called="0"
|
||||
local cleanup_called="0"
|
||||
|
||||
ask_proxy_config() {
|
||||
ask_called="1"
|
||||
@@ -105,6 +106,10 @@ EOF
|
||||
DOWNLOAD_URL=""
|
||||
}
|
||||
|
||||
cleanup_legacy_gost_installation() {
|
||||
cleanup_called="1"
|
||||
}
|
||||
|
||||
check_and_install_tcpkill() { :; }
|
||||
|
||||
systemctl() {
|
||||
@@ -133,6 +138,7 @@ EOF
|
||||
update_flux_agent >/dev/null
|
||||
|
||||
assert_equals "1" "$ask_called" "update_flux_agent should ask for proxy config before downloading"
|
||||
assert_equals "1" "$cleanup_called" "update_flux_agent should clean up legacy gost before restarting the agent"
|
||||
assert_equals "$(build_download_url)" "$DOWNLOAD_URL" "update_flux_agent should honor the prompted proxy choice"
|
||||
)
|
||||
|
||||
@@ -155,6 +161,198 @@ test_update_flux_agent_skips_proxy_prompt_when_not_installed() (
|
||||
assert_equals "0" "$ask_called" "update_flux_agent should not prompt for proxy config when the agent is missing"
|
||||
)
|
||||
|
||||
test_install_flux_agent_preserves_legacy_gost_when_download_fails() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
|
||||
INSTALL_DIR=$(mktemp -d)
|
||||
cat > "$INSTALL_DIR/flux_agent" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "old version"
|
||||
EOF
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
SERVER_ADDR="panel.example.com:443"
|
||||
SECRET="secret"
|
||||
DOWNLOAD_URL="https://example.com/gost"
|
||||
|
||||
local cleanup_called="0"
|
||||
local rc="0"
|
||||
|
||||
ask_proxy_config() { :; }
|
||||
ensure_download_url_initialized() { :; }
|
||||
get_config_params() { :; }
|
||||
check_and_install_tcpkill() { :; }
|
||||
cleanup_legacy_gost_installation() {
|
||||
cleanup_called="1"
|
||||
}
|
||||
systemctl() { return 0; }
|
||||
curl() { return 0; }
|
||||
|
||||
( install_flux_agent >/dev/null ) || rc="$?"
|
||||
|
||||
assert_equals "1" "$rc" "install_flux_agent should fail when the download artifact is missing"
|
||||
assert_equals "0" "$cleanup_called" "install_flux_agent should preserve legacy gost when download fails"
|
||||
[[ -f "$INSTALL_DIR/flux_agent" ]] || fail "install_flux_agent should keep the existing flux_agent binary when download fails"
|
||||
)
|
||||
|
||||
test_update_flux_agent_preserves_legacy_gost_when_download_fails() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
|
||||
INSTALL_DIR=$(mktemp -d)
|
||||
cat > "$INSTALL_DIR/flux_agent" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "old version"
|
||||
EOF
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
cat > "$INSTALL_DIR/flux_agent.new" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "stale version"
|
||||
EOF
|
||||
chmod +x "$INSTALL_DIR/flux_agent.new"
|
||||
|
||||
local cleanup_called="0"
|
||||
local rc="0"
|
||||
|
||||
ask_proxy_config() {
|
||||
PROXY_ENABLED="false"
|
||||
DOWNLOAD_URL="https://example.com/gost"
|
||||
}
|
||||
check_and_install_tcpkill() { :; }
|
||||
cleanup_legacy_gost_installation() {
|
||||
cleanup_called="1"
|
||||
}
|
||||
systemctl() { return 0; }
|
||||
curl() { return 0; }
|
||||
|
||||
update_flux_agent >/dev/null || rc="$?"
|
||||
|
||||
assert_equals "1" "$rc" "update_flux_agent should fail when the download artifact is missing"
|
||||
assert_equals "0" "$cleanup_called" "update_flux_agent should preserve legacy gost when download fails"
|
||||
[[ ! -f "$INSTALL_DIR/flux_agent.new" ]] || fail "update_flux_agent should remove stale download artifacts before retrying"
|
||||
)
|
||||
|
||||
test_install_flux_agent_writes_json_safe_config() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
|
||||
INSTALL_DIR=$(mktemp -d)
|
||||
SERVER_ADDR='panel"addr'
|
||||
SECRET='sec\ret"1'
|
||||
DOWNLOAD_URL="https://example.com/gost"
|
||||
|
||||
ask_proxy_config() { :; }
|
||||
ensure_download_url_initialized() { :; }
|
||||
get_config_params() { :; }
|
||||
check_and_install_tcpkill() { :; }
|
||||
cleanup_legacy_gost_installation() { :; }
|
||||
systemctl() { return 0; }
|
||||
curl() {
|
||||
local output=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
if [[ "$1" == "-o" ]]; then
|
||||
output="$2"
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
shift
|
||||
done
|
||||
|
||||
cat > "$output" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "new version"
|
||||
EOF
|
||||
chmod +x "$output"
|
||||
}
|
||||
|
||||
( install_flux_agent >/dev/null 2>/dev/null ) || true
|
||||
|
||||
local actual
|
||||
actual=$(<"$INSTALL_DIR/config.json")
|
||||
local expected=$'{\n "addr": "panel\\"addr",\n "secret": "sec\\\\ret\\"1"\n}'
|
||||
|
||||
assert_equals "$expected" "$actual" "install_flux_agent should JSON-escape config values"
|
||||
)
|
||||
|
||||
test_cleanup_legacy_gost_installation_removes_service_and_binary() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
|
||||
LEGACY_GOST_BINARY=$(mktemp)
|
||||
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
|
||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
|
||||
[Unit]
|
||||
Description=Gost Proxy Service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$LEGACY_GOST_CONFIG_DIR
|
||||
ExecStart=$LEGACY_GOST_CONFIG_DIR/gost
|
||||
EOF
|
||||
: > "$LEGACY_GOST_CONFIG_DIR/config.json"
|
||||
: > "$LEGACY_GOST_CONFIG_DIR/gost.json"
|
||||
|
||||
local systemctl_calls=""
|
||||
|
||||
systemctl() {
|
||||
systemctl_calls+=$'\n'"$*"
|
||||
if [[ "$1" == "list-units" ]]; then
|
||||
printf 'gost.service loaded active running\n'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup_legacy_gost_installation >/dev/null
|
||||
|
||||
if [[ -e "$LEGACY_GOST_BINARY" ]]; then
|
||||
fail "cleanup_legacy_gost_installation should remove the legacy gost binary"
|
||||
fi
|
||||
if [[ -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]]; then
|
||||
fail "cleanup_legacy_gost_installation should remove the legacy gost service file"
|
||||
fi
|
||||
[[ "$systemctl_calls" == *"stop gost"* ]] || fail "cleanup_legacy_gost_installation should stop the legacy gost service"
|
||||
[[ "$systemctl_calls" == *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should disable the legacy gost service"
|
||||
[[ "$systemctl_calls" == *"daemon-reload"* ]] || fail "cleanup_legacy_gost_installation should reload systemd after removing the legacy service"
|
||||
)
|
||||
|
||||
test_cleanup_legacy_gost_installation_preserves_unrelated_gost() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
|
||||
LEGACY_GOST_BINARY=$(mktemp)
|
||||
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
|
||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF'
|
||||
[Unit]
|
||||
Description=Unrelated Gost Service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/srv/custom-gost
|
||||
ExecStart=/usr/local/bin/gost -C /srv/custom-gost/gost.yaml
|
||||
EOF
|
||||
|
||||
local systemctl_calls=""
|
||||
|
||||
systemctl() {
|
||||
systemctl_calls+=$'\n'"$*"
|
||||
if [[ "$1" == "list-units" ]]; then
|
||||
printf 'gost.service loaded active running\n'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup_legacy_gost_installation >/dev/null
|
||||
|
||||
[[ -e "$LEGACY_GOST_BINARY" ]] || fail "cleanup_legacy_gost_installation should preserve unrelated gost binaries"
|
||||
[[ -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]] || fail "cleanup_legacy_gost_installation should preserve unrelated gost service files"
|
||||
[[ "$systemctl_calls" != *"stop gost"* ]] || fail "cleanup_legacy_gost_installation should not stop unrelated gost services"
|
||||
[[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services"
|
||||
)
|
||||
|
||||
test_install_script_accepts_proxy_url_env_without_prompt() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
@@ -303,6 +501,11 @@ test_install_script_asks_for_proxy_config
|
||||
test_install_script_recomputes_download_url_after_prompt
|
||||
test_update_flux_agent_asks_for_proxy_config
|
||||
test_update_flux_agent_skips_proxy_prompt_when_not_installed
|
||||
test_install_flux_agent_preserves_legacy_gost_when_download_fails
|
||||
test_update_flux_agent_preserves_legacy_gost_when_download_fails
|
||||
test_install_flux_agent_writes_json_safe_config
|
||||
test_cleanup_legacy_gost_installation_removes_service_and_binary
|
||||
test_cleanup_legacy_gost_installation_preserves_unrelated_gost
|
||||
test_install_script_accepts_proxy_url_env_without_prompt
|
||||
test_panel_install_script_can_disable_proxy
|
||||
test_panel_install_script_recomputes_compose_urls_after_prompt
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 多阶段构建 - 构建阶段
|
||||
FROM node:20.19.0 AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
RUN corepack prepare pnpm@10 --activate && corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
@@ -75,14 +75,18 @@
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-unused-imports": "4.1.4",
|
||||
"globals": "16.0.0",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "7.0.3"
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@babel/plugin-transform-modules-systemjs": "7.29.4",
|
||||
"fast-uri": "3.1.2",
|
||||
"serialize-javascript": "7.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+25
-33
@@ -4,6 +4,11 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@babel/plugin-transform-modules-systemjs': 7.29.4
|
||||
fast-uri: 3.1.2
|
||||
serialize-javascript: 7.0.5
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -199,8 +204,8 @@ importers:
|
||||
specifier: 16.0.0
|
||||
version: 16.0.0
|
||||
postcss:
|
||||
specifier: 8.5.6
|
||||
version: 8.5.6
|
||||
specifier: 8.5.10
|
||||
version: 8.5.10
|
||||
prettier:
|
||||
specifier: 3.5.3
|
||||
version: 3.5.3
|
||||
@@ -545,8 +550,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/plugin-transform-modules-systemjs@7.29.0':
|
||||
resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==}
|
||||
'@babel/plugin-transform-modules-systemjs@7.29.4':
|
||||
resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
@@ -2558,8 +2563,8 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
@@ -3489,8 +3494,8 @@ packages:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
postcss@8.5.10:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -3534,9 +3539,6 @@ packages:
|
||||
raf-schd@4.0.3:
|
||||
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
react-aria@3.48.0:
|
||||
resolution: {integrity: sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==}
|
||||
peerDependencies:
|
||||
@@ -3804,9 +3806,6 @@ packages:
|
||||
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3827,8 +3826,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
serialize-javascript@6.0.2:
|
||||
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
|
||||
serialize-javascript@7.0.5:
|
||||
resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
@@ -4690,7 +4690,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)':
|
||||
'@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||
@@ -4906,7 +4906,7 @@ snapshots:
|
||||
'@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0)
|
||||
@@ -5790,7 +5790,7 @@ snapshots:
|
||||
|
||||
'@rollup/plugin-terser@0.4.4(rollup@2.80.0)':
|
||||
dependencies:
|
||||
serialize-javascript: 6.0.2
|
||||
serialize-javascript: 7.0.5
|
||||
smob: 1.6.1
|
||||
terser: 5.46.1
|
||||
optionalDependencies:
|
||||
@@ -5958,7 +5958,7 @@ snapshots:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.1.11
|
||||
'@tailwindcss/oxide': 4.1.11
|
||||
postcss: 8.5.6
|
||||
postcss: 8.5.10
|
||||
tailwindcss: 4.1.11
|
||||
|
||||
'@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.2)(jiti@2.6.1)(terser@5.46.1))':
|
||||
@@ -6192,7 +6192,7 @@ snapshots:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
fast-uri: 3.1.2
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -6903,7 +6903,7 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
@@ -7994,7 +7994,7 @@ snapshots:
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss@8.5.6:
|
||||
postcss@8.5.10:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
@@ -8028,10 +8028,6 @@ snapshots:
|
||||
|
||||
raf-schd@4.0.3: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
react-aria@3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.1
|
||||
@@ -8315,7 +8311,7 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.6
|
||||
postcss: 8.5.10
|
||||
rolldown: 1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
@@ -8365,8 +8361,6 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
isarray: 2.0.5
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -8386,9 +8380,7 @@ snapshots:
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
serialize-javascript@6.0.2:
|
||||
dependencies:
|
||||
randombytes: 2.1.0
|
||||
serialize-javascript@7.0.5: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
|
||||
@@ -42,6 +42,9 @@ import type {
|
||||
MonitorAccessApiData,
|
||||
TunnelQualityApiItem,
|
||||
StorageSummaryApiData,
|
||||
SystemUpgradeCheckApiData,
|
||||
SystemUpgradeRunApiData,
|
||||
SystemUpgradeVersionApiData,
|
||||
} from "./types";
|
||||
|
||||
import axios from "axios";
|
||||
@@ -250,6 +253,8 @@ export const getConfigs = () =>
|
||||
Network.post<Record<string, string>>("/config/list");
|
||||
export const getConfigByName = (name: string) =>
|
||||
Network.post<{ name: string; value: string }>("/config/get", { name });
|
||||
export const getPublicConfigByName = (name: string) =>
|
||||
Network.post<{ name: string; value: string }>("/public/config/get", { name });
|
||||
export const updateConfigs = (configMap: Record<string, string>) =>
|
||||
Network.post("/config/update", configMap);
|
||||
export const updateConfig = (name: string, value: string) =>
|
||||
@@ -258,6 +263,24 @@ export const updateConfig = (name: string, value: string) =>
|
||||
export const getStorageSummary = () =>
|
||||
Network.get<StorageSummaryApiData>("/system/storage");
|
||||
|
||||
export const getSystemUpgradeVersion = () =>
|
||||
Network.post<SystemUpgradeVersionApiData>("/system/version");
|
||||
|
||||
export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") =>
|
||||
Network.post<SystemUpgradeCheckApiData>("/system/check-updates", {
|
||||
channel,
|
||||
});
|
||||
|
||||
export const runSystemUpgrade = (
|
||||
version?: string,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) =>
|
||||
Network.post<SystemUpgradeRunApiData>(
|
||||
"/system/upgrade",
|
||||
{ version: version || "", channel },
|
||||
{ timeout: 60 * 1000 },
|
||||
);
|
||||
|
||||
export const activateLicense = (licenseKey: string) =>
|
||||
Network.post("/license/activate", { license_key: licenseKey });
|
||||
|
||||
|
||||
@@ -200,6 +200,14 @@ export interface NodeReleaseApiItem {
|
||||
channel: "stable" | "dev";
|
||||
}
|
||||
|
||||
export interface SystemUpgradeReleaseApiItem {
|
||||
version: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
channel: "stable" | "dev";
|
||||
}
|
||||
|
||||
export interface UserPackageInfoApiData {
|
||||
userInfo: {
|
||||
flow: number;
|
||||
@@ -484,6 +492,35 @@ export interface StorageSummaryApiData {
|
||||
databaseSizeText: string;
|
||||
}
|
||||
|
||||
export interface SystemUpgradeCapabilityApiData {
|
||||
capable: boolean;
|
||||
reasons: string[];
|
||||
deployDir: string;
|
||||
backendContainer: string;
|
||||
}
|
||||
|
||||
export interface SystemUpgradeVersionApiData {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
channel: "stable" | "dev";
|
||||
reason?: string;
|
||||
capability: SystemUpgradeCapabilityApiData;
|
||||
}
|
||||
|
||||
export interface SystemUpgradeCheckApiData extends SystemUpgradeVersionApiData {
|
||||
releases: SystemUpgradeReleaseApiItem[];
|
||||
}
|
||||
|
||||
export interface SystemUpgradeRunApiData {
|
||||
version: string;
|
||||
channel: "stable" | "dev";
|
||||
composeAsset: string;
|
||||
helperContainer: string;
|
||||
backendImageId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MonitorNodeApiItem {
|
||||
id: number;
|
||||
inx: number;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getConfigByName, getConfigs } from "@/api";
|
||||
import { getConfigByName, getConfigs, getPublicConfigByName } from "@/api";
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
|
||||
@@ -8,9 +9,73 @@ const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const DEFAULT_FAVICON = "/favicon.ico";
|
||||
const FAVICON_LINK_ID = "app-favicon";
|
||||
const PUBLIC_BRAND_CONFIG_KEYS = [
|
||||
"app_name",
|
||||
"app_logo",
|
||||
"app_favicon",
|
||||
"app_bg_image",
|
||||
] as const;
|
||||
const SENSITIVE_CONFIG_KEYS = new Set([
|
||||
"jwt_secret",
|
||||
"license_key",
|
||||
"cloudflare_secret_key",
|
||||
]);
|
||||
const GITHUB_REPO =
|
||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
const shouldPersistConfigKey = (key: string) => {
|
||||
return !SENSITIVE_CONFIG_KEYS.has(key.trim().toLowerCase());
|
||||
};
|
||||
|
||||
const purgeSensitiveConfigCache = () => {
|
||||
SENSITIVE_CONFIG_KEYS.forEach((key) => {
|
||||
localStorage.removeItem(CACHE_PREFIX + key);
|
||||
});
|
||||
};
|
||||
|
||||
const readCachedConfigs = (keys: readonly string[]) => {
|
||||
const cachedConfigs: Record<string, string> = {};
|
||||
let hasCachedData = false;
|
||||
|
||||
keys.forEach((key) => {
|
||||
const cachedValue = configCache.get(key);
|
||||
|
||||
if (cachedValue !== null) {
|
||||
cachedConfigs[key] = cachedValue;
|
||||
hasCachedData = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { cachedConfigs, hasCachedData };
|
||||
};
|
||||
|
||||
const fetchPublicBrandConfigs = async (): Promise<Record<string, string>> => {
|
||||
const publicConfigMap: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
PUBLIC_BRAND_CONFIG_KEYS.map(async (key) => {
|
||||
try {
|
||||
const response = await getPublicConfigByName(key);
|
||||
|
||||
if (
|
||||
response.code === 0 &&
|
||||
response.data &&
|
||||
typeof response.data.value === "string"
|
||||
) {
|
||||
const value = response.data.value;
|
||||
|
||||
publicConfigMap[key] = value;
|
||||
configCache.set(key, value);
|
||||
}
|
||||
} catch {
|
||||
// ignore single key fetch error
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return publicConfigMap;
|
||||
};
|
||||
|
||||
const getInitialConfig = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
@@ -26,6 +91,8 @@ const getInitialConfig = () => {
|
||||
};
|
||||
}
|
||||
|
||||
purgeSensitiveConfigCache();
|
||||
|
||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
||||
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
|
||||
const cachedAppFavicon =
|
||||
@@ -77,7 +144,15 @@ export const configCache = {
|
||||
|
||||
// 设置缓存的配置
|
||||
set: (key: string, value: string): void => {
|
||||
const cacheKey = CACHE_PREFIX + key;
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
|
||||
if (!shouldPersistConfigKey(normalizedKey)) {
|
||||
configCache.remove(normalizedKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = CACHE_PREFIX + normalizedKey;
|
||||
|
||||
localStorage.setItem(cacheKey, value);
|
||||
},
|
||||
@@ -119,7 +194,9 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
) {
|
||||
const value = response.data.value;
|
||||
|
||||
configCache.set(key, value);
|
||||
if (shouldPersistConfigKey(key)) {
|
||||
configCache.set(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -129,46 +206,19 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
|
||||
// 获取所有配置(优先从缓存)
|
||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
// 尝试从缓存获取所有配置
|
||||
const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"];
|
||||
const cachedConfigs: Record<string, string> = {};
|
||||
let hasCachedData = false;
|
||||
const { cachedConfigs, hasCachedData } = readCachedConfigs(
|
||||
PUBLIC_BRAND_CONFIG_KEYS,
|
||||
);
|
||||
|
||||
configKeys.forEach((key) => {
|
||||
const cachedValue = configCache.get(key);
|
||||
if (!isLoggedIn()) {
|
||||
const publicConfigs = await fetchPublicBrandConfigs();
|
||||
|
||||
if (cachedValue !== null) {
|
||||
cachedConfigs[key] = cachedValue;
|
||||
hasCachedData = true;
|
||||
if (Object.keys(publicConfigs).length > 0) {
|
||||
return { ...cachedConfigs, ...publicConfigs };
|
||||
}
|
||||
});
|
||||
|
||||
const fetchPublicConfigs = async (): Promise<Record<string, string>> => {
|
||||
const publicConfigMap: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
configKeys.map(async (key) => {
|
||||
try {
|
||||
const response = await getConfigByName(key);
|
||||
|
||||
if (
|
||||
response.code === 0 &&
|
||||
response.data &&
|
||||
typeof response.data.value === "string"
|
||||
) {
|
||||
const value = response.data.value;
|
||||
|
||||
publicConfigMap[key] = value;
|
||||
configCache.set(key, value);
|
||||
}
|
||||
} catch {
|
||||
// ignore single key fetch error
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return publicConfigMap;
|
||||
};
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
// 从API获取最新配置
|
||||
try {
|
||||
@@ -177,9 +227,19 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
if (response.code === 0 && response.data) {
|
||||
const configs = response.data;
|
||||
|
||||
// 将所有配置存入缓存
|
||||
// 仅将安全配置存入缓存,敏感项会从 localStorage 中移除
|
||||
Object.entries(configs).forEach(([key, value]) => {
|
||||
configCache.set(key, value as string);
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
|
||||
if (SENSITIVE_CONFIG_KEYS.has(normalizedKey)) {
|
||||
configCache.remove(normalizedKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldPersistConfigKey(normalizedKey)) {
|
||||
configCache.set(normalizedKey, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
return configs;
|
||||
@@ -189,14 +249,14 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
return await fetchPublicConfigs();
|
||||
return await fetchPublicBrandConfigs();
|
||||
} catch {
|
||||
// API失败时返回缓存的数据
|
||||
if (hasCachedData) {
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
return await fetchPublicConfigs();
|
||||
return await fetchPublicBrandConfigs();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,7 +315,17 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
|
||||
|
||||
Object.entries(resolvedConfigMap).forEach(([key, value]) => {
|
||||
configCache.set(key, String(value));
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
|
||||
if (SENSITIVE_CONFIG_KEYS.has(normalizedKey)) {
|
||||
configCache.remove(normalizedKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldPersistConfigKey(normalizedKey)) {
|
||||
configCache.set(normalizedKey, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const hasAppName = Object.prototype.hasOwnProperty.call(
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import type {
|
||||
SystemUpgradeCheckApiData,
|
||||
SystemUpgradeRunApiData,
|
||||
SystemUpgradeReleaseApiItem,
|
||||
SystemUpgradeVersionApiData,
|
||||
} from "@/api/types";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -27,6 +34,9 @@ import {
|
||||
getAnnouncement,
|
||||
updateAnnouncement,
|
||||
getStorageSummary,
|
||||
getSystemUpgradeVersion,
|
||||
checkSystemUpgrade,
|
||||
runSystemUpgrade,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||
@@ -292,6 +302,19 @@ export default function ConfigPage() {
|
||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
const [systemUpgradeInfo, setSystemUpgradeInfo] =
|
||||
useState<SystemUpgradeVersionApiData | null>(null);
|
||||
const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false);
|
||||
const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false);
|
||||
const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true);
|
||||
const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false);
|
||||
const [systemUpgradeReleases, setSystemUpgradeReleases] = useState<
|
||||
SystemUpgradeReleaseApiItem[]
|
||||
>([]);
|
||||
const [systemUpgradeCheckedChannel, setSystemUpgradeCheckedChannel] =
|
||||
useState<UpdateReleaseChannel | null>(null);
|
||||
const [systemUpgradeSelectedVersion, setSystemUpgradeSelectedVersion] =
|
||||
useState("");
|
||||
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
@@ -299,6 +322,26 @@ export default function ConfigPage() {
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
const [storageSummary, setStorageSummary] = useState("加载中...");
|
||||
const systemUpgradeReleasesMatchChannel =
|
||||
systemUpgradeCheckedChannel === updateChannel;
|
||||
const systemUpgradeHasConfirmedUpdate = Boolean(
|
||||
systemUpgradeInfo?.hasUpdate &&
|
||||
systemUpgradeReleasesMatchChannel &&
|
||||
systemUpgradeReleases.length > 0,
|
||||
);
|
||||
const canTriggerSystemUpgrade = Boolean(
|
||||
!systemUpgradeLoading &&
|
||||
!systemUpgradeChecking &&
|
||||
!systemUpgradeExecuting &&
|
||||
systemUpgradeInfo?.capability.capable !== false,
|
||||
);
|
||||
const canOpenSystemUpgradeModal = Boolean(
|
||||
systemUpgradeInfo?.capability.capable &&
|
||||
systemUpgradeHasConfirmedUpdate &&
|
||||
!systemUpgradeLoading &&
|
||||
!systemUpgradeChecking &&
|
||||
!systemUpgradeExecuting,
|
||||
);
|
||||
|
||||
const canGoBack =
|
||||
typeof window !== "undefined" &&
|
||||
@@ -373,11 +416,38 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSystemUpgradeInfo = async (channel = updateChannel) => {
|
||||
setSystemUpgradeLoading(true);
|
||||
try {
|
||||
const response = await getSystemUpgradeVersion();
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
setSystemUpgradeInfo({
|
||||
...response.data,
|
||||
channel,
|
||||
hasUpdate:
|
||||
response.data.channel === channel ? response.data.hasUpdate : false,
|
||||
latestVersion:
|
||||
response.data.channel === channel
|
||||
? response.data.latestVersion
|
||||
: "",
|
||||
});
|
||||
} else {
|
||||
setSystemUpgradeInfo(null);
|
||||
}
|
||||
} catch {
|
||||
setSystemUpgradeInfo(null);
|
||||
} finally {
|
||||
setSystemUpgradeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadConfigs(initialConfigs);
|
||||
loadAnnouncement();
|
||||
loadStorageSummary();
|
||||
void loadSystemUpgradeInfo();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -417,11 +487,94 @@ export default function ConfigPage() {
|
||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||
setUpdateChannel(channel);
|
||||
setUpdateReleaseChannel(channel);
|
||||
setSystemUpgradeSelectedVersion("");
|
||||
setSystemUpgradeReleases([]);
|
||||
setSystemUpgradeCheckedChannel(null);
|
||||
void loadSystemUpgradeInfo(channel);
|
||||
toast.success(
|
||||
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckSystemUpgrade = async () => {
|
||||
const channel = updateChannel;
|
||||
|
||||
setSystemUpgradeChecking(true);
|
||||
try {
|
||||
const response = await checkSystemUpgrade(channel);
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
const data = response.data as SystemUpgradeCheckApiData;
|
||||
|
||||
setSystemUpgradeInfo(data);
|
||||
setSystemUpgradeReleases(data.releases || []);
|
||||
setSystemUpgradeCheckedChannel(channel);
|
||||
setSystemUpgradeSelectedVersion("");
|
||||
if (data.latestVersion && !data.hasUpdate) {
|
||||
toast.success("当前已是最新版本");
|
||||
|
||||
return false;
|
||||
}
|
||||
toast.success(
|
||||
data.latestVersion
|
||||
? `已检查到最新版本 ${data.latestVersion}`
|
||||
: "未获取到可用版本",
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
data.capability.capable && data.hasUpdate && data.releases?.length,
|
||||
);
|
||||
} else {
|
||||
setSystemUpgradeReleases([]);
|
||||
setSystemUpgradeCheckedChannel(null);
|
||||
toast.error(response.msg || "检查更新失败");
|
||||
}
|
||||
} catch {
|
||||
setSystemUpgradeReleases([]);
|
||||
setSystemUpgradeCheckedChannel(null);
|
||||
toast.error("检查更新失败,请重试");
|
||||
} finally {
|
||||
setSystemUpgradeChecking(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleOpenSystemUpgradeModal = async () => {
|
||||
if (!canOpenSystemUpgradeModal) {
|
||||
const checked = await handleCheckSystemUpgrade();
|
||||
|
||||
if (!checked) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSystemUpgradeModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmSystemUpgrade = async () => {
|
||||
setSystemUpgradeExecuting(true);
|
||||
try {
|
||||
const response = await runSystemUpgrade(
|
||||
systemUpgradeSelectedVersion || undefined,
|
||||
updateChannel,
|
||||
);
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
const data = response.data as SystemUpgradeRunApiData;
|
||||
|
||||
setSystemUpgradeModalOpen(false);
|
||||
setSystemUpgradeSelectedVersion("");
|
||||
toast.success(data.message || "升级已触发,请稍后刷新页面");
|
||||
} else {
|
||||
toast.error(response.msg || "面板升级失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("面板升级失败,请重试");
|
||||
} finally {
|
||||
setSystemUpgradeExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateLicense = async () => {
|
||||
if (!licenseKeyInput.trim()) {
|
||||
toast.error("请输入有效的商业授权码");
|
||||
@@ -1329,6 +1482,7 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
|
||||
<Select
|
||||
aria-label="更新通道"
|
||||
selectedKeys={[updateChannel]}
|
||||
size="md"
|
||||
variant="bordered"
|
||||
@@ -1367,6 +1521,164 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="my-2" />
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-divider bg-default-50/60 p-4 dark:bg-default-100/10">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
面板自升级
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
检查当前版本、可用发布并在容器环境中触发面板升级。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{systemUpgradeLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider bg-background px-4 py-3 text-sm text-default-500">
|
||||
<Spinner size="sm" />
|
||||
正在加载升级状态...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-lg border border-divider bg-background px-4 py-4 text-sm text-default-700 dark:text-default-300">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-default-500">当前版本</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{systemUpgradeInfo?.currentVersion || "未获取到版本信息"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-default-500">最新版本</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{systemUpgradeInfo?.latestVersion || "未获取到可用版本"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-default-500">当前通道</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{systemUpgradeInfo?.channel === "dev"
|
||||
? "开发版"
|
||||
: systemUpgradeInfo?.channel === "stable"
|
||||
? "稳定版"
|
||||
: updateChannel === "dev"
|
||||
? "开发版"
|
||||
: "稳定版"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-default-500">升级能力</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{systemUpgradeInfo?.capability.capable
|
||||
? "可升级"
|
||||
: "当前不可升级"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-default-500">部署目录</p>
|
||||
<p className="mt-1 break-all font-medium">
|
||||
{systemUpgradeInfo?.capability.deployDir ||
|
||||
"未获取到部署目录"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-default-500">后端容器</p>
|
||||
<p className="mt-1 break-all font-medium">
|
||||
{systemUpgradeInfo?.capability.backendContainer ||
|
||||
"未获取到容器信息"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!systemUpgradeInfo?.capability.capable && (
|
||||
<div className="rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-warning-800 dark:border-warning-900/40 dark:bg-warning-950/30 dark:text-warning-200">
|
||||
<p className="text-xs font-medium">当前无法升级</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-4 text-xs">
|
||||
{(systemUpgradeInfo?.capability.reasons?.length
|
||||
? systemUpgradeInfo.capability.reasons
|
||||
: ["暂未获取到不可升级原因"]
|
||||
).map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
可用发布版本
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
选择指定版本后执行升级;留空则使用当前通道下最新可用版本。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
aria-label="目标版本"
|
||||
isDisabled={
|
||||
!systemUpgradeReleasesMatchChannel ||
|
||||
systemUpgradeReleases.length === 0 ||
|
||||
systemUpgradeExecuting
|
||||
}
|
||||
placeholder={
|
||||
systemUpgradeReleasesMatchChannel &&
|
||||
systemUpgradeReleases.length > 0
|
||||
? "留空时自动选择最新版本"
|
||||
: "请先检查当前通道更新"
|
||||
}
|
||||
selectedKeys={
|
||||
systemUpgradeSelectedVersion
|
||||
? [systemUpgradeSelectedVersion]
|
||||
: []
|
||||
}
|
||||
size="md"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
setSystemUpgradeSelectedVersion(selected || "");
|
||||
}}
|
||||
>
|
||||
{(systemUpgradeReleasesMatchChannel
|
||||
? systemUpgradeReleases
|
||||
: []
|
||||
).map((release) => (
|
||||
<SelectItem
|
||||
key={release.version}
|
||||
description={release.publishedAt || "暂无发布时间"}
|
||||
>
|
||||
{release.name || release.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
isLoading={systemUpgradeChecking}
|
||||
variant="flat"
|
||||
onPress={handleCheckSystemUpgrade}
|
||||
>
|
||||
检查更新
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={!canTriggerSystemUpgrade}
|
||||
isLoading={systemUpgradeExecuting}
|
||||
onPress={handleOpenSystemUpgradeModal}
|
||||
>
|
||||
立即升级
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
|
||||
<Button
|
||||
color="primary"
|
||||
@@ -1606,6 +1918,64 @@ export default function ConfigPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
classNames={{
|
||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||
}}
|
||||
isOpen={systemUpgradeModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!systemUpgradeExecuting) {
|
||||
setSystemUpgradeModalOpen(open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>确认面板升级</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-3 text-sm text-default-700 dark:text-default-300">
|
||||
<p>
|
||||
升级过程需要访问 Docker
|
||||
Socket,并会在短时间内中断当前面板服务。
|
||||
</p>
|
||||
<p>
|
||||
请确认已经允许面板管理容器与宿主机 Docker
|
||||
交互,并且可以接受升级期间的临时不可用。
|
||||
</p>
|
||||
<div className="space-y-2 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-warning-800 dark:border-warning-900/40 dark:bg-warning-950/30 dark:text-warning-200">
|
||||
<p className="text-xs font-medium">升级前请确认</p>
|
||||
<ul className="list-disc space-y-1 pl-4 text-xs">
|
||||
<li>Docker Socket 可用且挂载权限正常。</li>
|
||||
<li>当前面板允许短暂停止和重启。</li>
|
||||
<li>已选择正确的更新通道与目标版本。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
isDisabled={systemUpgradeExecuting}
|
||||
variant="light"
|
||||
onPress={onClose}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={systemUpgradeExecuting}
|
||||
isLoading={systemUpgradeExecuting}
|
||||
onPress={handleConfirmSystemUpgrade}
|
||||
>
|
||||
确认升级
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Floating Save Button (FAB) */}
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { VersionFooter } from "@/components/version-footer";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
|
||||
import { login, LoginData, checkCaptcha, getPublicConfigByName } from "@/api";
|
||||
import { writeLoginSession } from "@/utils/session";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function IndexPage() {
|
||||
if (checkResponse.data === 0) {
|
||||
await performLogin();
|
||||
} else {
|
||||
const configResp = await getConfigByName("cloudflare_site_key");
|
||||
const configResp = await getPublicConfigByName("cloudflare_site_key");
|
||||
|
||||
if (configResp.code === 0 && configResp.data && configResp.data.value) {
|
||||
setSiteKey(configResp.data.value);
|
||||
|
||||
@@ -2582,11 +2582,16 @@ export default function NodePage() {
|
||||
/>
|
||||
|
||||
{/* 高级配置 */}
|
||||
<Accordion variant="bordered">
|
||||
<Accordion className="px-0" variant="light">
|
||||
<AccordionItem
|
||||
key="advanced"
|
||||
aria-label="高级配置"
|
||||
title="高级配置"
|
||||
className="border-b-0 [&_[data-slot=accordion-trigger]]:no-underline [&_[data-slot=accordion-trigger]]:hover:no-underline"
|
||||
title={
|
||||
<span className="text-small text-default-500 font-medium">
|
||||
高级配置
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 pb-2">
|
||||
<Input
|
||||
@@ -2683,10 +2688,10 @@ export default function NodePage() {
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? "opacity-70" : ""}`}
|
||||
className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-content1/30 dark:bg-content1/20 p-3 rounded-md border border-divider ${protocolDisabled ? "opacity-70" : ""}`}
|
||||
>
|
||||
{/* HTTP tile */}
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="px-3 py-3 rounded-lg bg-content1/55 dark:bg-content1/35 border border-divider hover:border-primary-200 dark:hover:border-primary-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -2727,7 +2732,7 @@ export default function NodePage() {
|
||||
</div>
|
||||
|
||||
{/* TLS tile */}
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="px-3 py-3 rounded-lg bg-content1/55 dark:bg-content1/35 border border-divider hover:border-primary-200 dark:hover:border-primary-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -2771,7 +2776,7 @@ export default function NodePage() {
|
||||
</div>
|
||||
|
||||
{/* SOCKS tile */}
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="px-3 py-3 rounded-lg bg-content1/55 dark:bg-content1/35 border border-divider hover:border-primary-200 dark:hover:border-primary-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
||||
+151
-141
@@ -204,6 +204,7 @@ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
const toSafeString = (value: unknown): string => {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -265,11 +266,13 @@ const normalizeBestExitState = (value: unknown): BestExitState | undefined => {
|
||||
|
||||
const bestExitOwnerRoleText = (role?: string) => {
|
||||
if (role === "chain") return "中转";
|
||||
|
||||
return "入口";
|
||||
};
|
||||
|
||||
const bestExitDetailTitle = (state?: BestExitState) => {
|
||||
if (!state?.items?.length) return undefined;
|
||||
|
||||
return state.items
|
||||
.map(
|
||||
(item) =>
|
||||
@@ -2268,147 +2271,6 @@ export default function TunnelPage() {
|
||||
<SelectItem key="2">隧道转发</SelectItem>
|
||||
</Select>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Select
|
||||
errorMessage={errors.flow}
|
||||
isInvalid={!!errors.flow}
|
||||
label="流量计算"
|
||||
placeholder="请选择流量计算方式"
|
||||
selectedKeys={[form.flow.toString()]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
flow: parseInt(selectedKey),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="1">单向计算(仅上传)</SelectItem>
|
||||
<SelectItem key="2">双向计算(上传+下载)</SelectItem>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
max={100}
|
||||
min={0.01}
|
||||
placeholder="例如:0.5 或 1 或 2"
|
||||
step="any"
|
||||
type="number"
|
||||
value={form.trafficRatio.toString()}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
trafficRatio: parseFloat(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||
errorMessage={errors.inIp}
|
||||
isInvalid={!!errors.inIp}
|
||||
label="入口IP"
|
||||
maxRows={5}
|
||||
minRows={3}
|
||||
placeholder="一行一个IP地址或域名,例如: 192.168.1.100 example.com"
|
||||
value={form.inIp}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, inIp: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.type === 2 && (
|
||||
<Select
|
||||
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
||||
label="隧道连接地址偏好"
|
||||
placeholder="自动选择"
|
||||
selectedKeys={[form.ipPreference || ""]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ipPreference: selectedKey || "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectItem key="v4">优先IPv4</SelectItem>
|
||||
<SelectItem key="v6">优先IPv6</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Accordion className="px-0" variant="light">
|
||||
<AccordionItem
|
||||
key="advanced"
|
||||
aria-label="高级设置"
|
||||
className="border-b-0 [&_[data-slot=accordion-trigger]]:no-underline [&_[data-slot=accordion-trigger]]:hover:no-underline"
|
||||
title={
|
||||
<span className="text-small text-default-500 font-medium">
|
||||
高级设置
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 pb-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">质量检测目标</div>
|
||||
<p className="text-xs text-default-500 mt-0.5">
|
||||
用于实时隧道质量检测、诊断目标和 best
|
||||
最优出口评分,留空使用 www.bing.com:443
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_140px] gap-3">
|
||||
<Input
|
||||
errorMessage={errors.probeTargetHost}
|
||||
isInvalid={!!errors.probeTargetHost}
|
||||
label="Host"
|
||||
placeholder="www.bing.com"
|
||||
value={form.probeTargetHost || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
probeTargetHost: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
errorMessage={errors.probeTargetPort}
|
||||
isInvalid={!!errors.probeTargetPort}
|
||||
label="Port"
|
||||
max={65535}
|
||||
min={1}
|
||||
placeholder="443"
|
||||
type="number"
|
||||
value={
|
||||
form.probeTargetPort
|
||||
? String(form.probeTargetPort)
|
||||
: ""
|
||||
}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
probeTargetPort: e.target.value
|
||||
? Number(e.target.value)
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<Divider />
|
||||
<h3 className="text-lg font-semibold">入口配置</h3>
|
||||
|
||||
@@ -3216,6 +3078,154 @@ export default function TunnelPage() {
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Accordion className="px-0" variant="light">
|
||||
<AccordionItem
|
||||
key="advanced"
|
||||
aria-label="高级设置"
|
||||
className="border-b-0 [&_[data-slot=accordion-trigger]]:no-underline [&_[data-slot=accordion-trigger]]:hover:no-underline"
|
||||
title={
|
||||
<span className="text-small text-default-500 font-medium">
|
||||
高级设置
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Select
|
||||
errorMessage={errors.flow}
|
||||
isInvalid={!!errors.flow}
|
||||
label="流量计算"
|
||||
placeholder="请选择流量计算方式"
|
||||
selectedKeys={[form.flow.toString()]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
flow: parseInt(selectedKey),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="1">单向计算(仅上传)</SelectItem>
|
||||
<SelectItem key="2">
|
||||
双向计算(上传+下载)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
max={100}
|
||||
min={0.01}
|
||||
placeholder="例如:0.5 或 1 或 2"
|
||||
step="any"
|
||||
type="number"
|
||||
value={form.trafficRatio.toString()}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
trafficRatio: parseFloat(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||
errorMessage={errors.inIp}
|
||||
isInvalid={!!errors.inIp}
|
||||
label="入口IP"
|
||||
maxRows={5}
|
||||
minRows={3}
|
||||
placeholder="一行一个IP地址或域名,例如: 192.168.1.100 example.com"
|
||||
value={form.inIp}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inIp: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.type === 2 && (
|
||||
<Select
|
||||
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
||||
label="隧道连接地址偏好"
|
||||
placeholder="自动选择"
|
||||
selectedKeys={[form.ipPreference || ""]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ipPreference: selectedKey || "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectItem key="v4">优先IPv4</SelectItem>
|
||||
<SelectItem key="v6">优先IPv6</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
质量检测目标
|
||||
</div>
|
||||
<p className="text-xs text-default-500 mt-0.5">
|
||||
用于实时隧道质量检测、诊断目标和 best
|
||||
最优出口评分,留空使用 www.bing.com:443
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_140px] gap-3">
|
||||
<Input
|
||||
errorMessage={errors.probeTargetHost}
|
||||
isInvalid={!!errors.probeTargetHost}
|
||||
label="Host"
|
||||
placeholder="www.bing.com"
|
||||
value={form.probeTargetHost || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
probeTargetHost: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
errorMessage={errors.probeTargetPort}
|
||||
isInvalid={!!errors.probeTargetPort}
|
||||
label="Port"
|
||||
max={65535}
|
||||
min={1}
|
||||
placeholder="443"
|
||||
type="number"
|
||||
value={
|
||||
form.probeTargetPort
|
||||
? String(form.probeTargetPort)
|
||||
: ""
|
||||
}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
probeTargetPort: e.target.value
|
||||
? Number(e.target.value)
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -160,7 +160,6 @@ export function ModalContent({
|
||||
showCloseButton={false}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">Modal Dialog</DialogTitle>
|
||||
{renderedChildren}
|
||||
</BaseDialogContent>
|
||||
);
|
||||
@@ -173,15 +172,17 @@ export function ModalHeader({
|
||||
const context = useModalContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg font-semibold",
|
||||
context?.classNames?.header,
|
||||
className,
|
||||
)}
|
||||
data-slot="modal-header"
|
||||
{...props}
|
||||
/>
|
||||
<DialogTitle asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg font-semibold",
|
||||
context?.classNames?.header,
|
||||
className,
|
||||
)}
|
||||
data-slot="modal-header"
|
||||
{...props}
|
||||
/>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ClassNameMap {
|
||||
}
|
||||
|
||||
export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||
"aria-label"?: string;
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
className?: string;
|
||||
classNames?: ClassNameMap;
|
||||
@@ -146,6 +147,7 @@ function textSizeClass(size: SelectProps["size"]) {
|
||||
}
|
||||
|
||||
export function Select<T>({
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
className,
|
||||
classNames,
|
||||
@@ -365,6 +367,7 @@ export function Select<T>({
|
||||
aria-controls={`${generatedId}-listbox`}
|
||||
aria-expanded={isExpanded}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={label ? undefined : ariaLabel}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md border border-input bg-background px-3 py-2 text-left shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isDisabled ? "cursor-not-allowed opacity-60" : "",
|
||||
@@ -398,6 +401,7 @@ export function Select<T>({
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
aria-label={label ? undefined : ariaLabel}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-input bg-background px-3 py-2 text-foreground shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]",
|
||||
sizeClass(size),
|
||||
|
||||
Reference in New Issue
Block a user