Compare commits

..

26 Commits

Author SHA1 Message Date
sagit 032b0f0cfd fix: serialize websocket writes in realtime server (#510) 2026-05-17 21:52:54 +08:00
sagitchu 7008717a49 fix(ws): serialize websocket writes in realtime server 2026-05-17 21:48:37 +08:00
sagit 8552a70355 fix: harden panel self-upgrade (#506)
This fixes panel self-upgrade and monitor realtime access for permitted
non-admin users.

Changes:
- Reuse GitHub proxy config for system upgrade release/API fetches.
- Make panel self-upgrade helper fail fast on compose command errors.
- Allow users with explicit monitor permission to connect to the
realtime websocket and receive broadcasts, not just admins.

Validation:
- cd go-backend && GOCACHE=/private/tmp/flvx-gocache go test
./internal/http/handler -count=1
- cd go-backend && go test ./internal/ws -count=1
2026-05-15 23:54:27 +08:00
sagitchu b2454e86c9 fix: allow monitor realtime websocket access 2026-05-15 23:37:08 +08:00
sagitchu 312c9a9c5c fix: harden panel self-upgrade 2026-05-15 23:16:14 +08:00
sagit e1324b8c8c fix: update dependabot vulnerabilities (#505)
* docs: add dependabot remediation design

* docs: add dependabot remediation plan

* fix: update backend pgx dependency

* fix: update frontend vulnerable dependencies

* fix: update gost quic dependencies

* fix: migrate gost dtls dependency

* fix: sync gost main dependencies

* fix: enable webtransport stream reset partial delivery

* fix: restore backend bcrypt dependency
2026-05-15 00:16:29 +08:00
sagit c034d0d41f fix: allow public config fallback for cached login (#504)
## Summary
- allow cached/old login clients to read public config keys through
`/api/v1/config/get` without a valid token
- keep sensitive config keys blocked from unauthenticated access
- stabilize the system upgrade fail-fast test by avoiding live
stable-release lookup and using POSIX shell syntax

## Test Plan
- `cd go-backend && go test ./...`
2026-05-14 18:46:43 +08:00
sagitchu fd3ecc38ef fix: allow public config fallback for cached login 2026-05-14 18:45:12 +08:00
sagit 2eee506716 fix: harden auth, config access, and backups (#503)
## Summary
- Migrate password storage to bcrypt with legacy MD5 verification-only
support and best-effort upgrade on successful auth.
- Revoke JWTs on auth-state changes, align WebSocket admin auth, and
split public config reads from protected config reads.
- Filter sensitive configs from backup export/import and update contract
coverage for the new security boundaries.

## Test Plan
- [x] `go test ./... -count=1`
- [x] `pnpm run lint`
- [x] `pnpm run build`
2026-05-14 11:21:45 +08:00
sagitchu abf13bdac9 fix: close remaining security remediation gaps 2026-05-14 11:10:06 +08:00
sagitchu f0facf6703 fix: block sensitive config writes 2026-05-14 10:37:29 +08:00
sagitchu 583a3834f9 fix: expire websocket admin sessions 2026-05-14 01:24:03 +08:00
sagitchu bd13477fa3 fix: stop caching sensitive configs in browser 2026-05-14 00:34:52 +08:00
sagitchu 106a30bf9d fix: tighten websocket auth and client cache handling 2026-05-14 00:24:56 +08:00
sagitchu 465815cf34 fix: harden auth, config access, and backups 2026-05-13 23:53:06 +08:00
sagitchu ec9fb77eb5 docs: add security remediation design spec 2026-05-13 14:17:51 +08:00
sagitchu 7d63dd4cc3 docs: add proxy protocol analysis and panel self-upgrade plans 2026-05-13 10:49:33 +08:00
sagit 4cfa6adee7 fix: Docker build pnpm/corepack compatibility (#501)
## Summary

- `node:20.19.0` + `corepack` + `pnpm@11` →
`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`
- `pnpm@11` blocks `@tailwindcss/oxide` build scripts by default
- Fix: `node:22-alpine` + `corepack prepare pnpm@10 --activate &&
corepack enable pnpm`

## Verification (all local)

| Check | Result |
|-------|--------|
| `docker build ./vite-frontend` | ✅ |
| `go test ./...` | ✅ 498 passed |
| `pnpm run build` | ✅ |
| `pnpm run lint` | ✅ |
2026-05-07 21:26:54 +08:00
sagitchu bc8f2ec8a1 fix: use corepack prepare pnpm@10 for Docker build compatibility
- node:22-alpine + corepack + pnpm@11 hits ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING
- corepack enable pnpm@10 is invalid syntax; use corepack prepare + enable
- pnpm@10 avoids the build script approval issue entirely
- Verified: docker build, pnpm build, pnpm lint, go test all pass locally
2026-05-07 21:24:47 +08:00
sagit 9a37c2f603 fix: pin pnpm to v10 in Dockerfile (#500)
Pin pnpm to v10 to avoid v11 build script issues in Docker build.
2026-05-07 21:12:57 +08:00
sagitchu ff6d46ddaf fix: pin pnpm to v10 in Dockerfile to avoid v11 build script issues
pnpm v11 blocks build scripts by default and the onlyBuiltDependencies
config is difficult to set in Docker build context. Pin to pnpm@10.
2026-05-07 21:10:41 +08:00
sagit 723534faea fix: use pnpm-workspace.yaml for onlyBuiltDependencies (#499)
Create pnpm-workspace.yaml inline in Dockerfile for pnpm v11 build
scripts.
2026-05-07 20:57:50 +08:00
sagitchu 73490a9be6 fix: use pnpm-workspace.yaml for onlyBuiltDependencies
Create pnpm-workspace.yaml inline in Dockerfile to allow
@tailwindcss/oxide build scripts in pnpm v11.
2026-05-07 20:55:42 +08:00
sagit 5a327459f7 fix: use .npmrc for pnpm onlyBuiltDependencies (#498)
Use .npmrc file for pnpm v11 build scripts approval.
2026-05-07 20:43:29 +08:00
sagitchu f307e7d5eb fix: use .npmrc for pnpm onlyBuiltDependencies config
pnpm config set doesn't support onlyBuiltDependencies in global config.
Use .npmrc file instead.
2026-05-07 20:41:05 +08:00
sagit fdd72979b6 fix: approve @tailwindcss/oxide build script for pnpm v11 (#497)
pnpm v11 blocks build scripts by default. Allow @tailwindcss/oxide via
onlyBuiltDependencies.
2026-05-07 20:26:24 +08:00
57 changed files with 5285 additions and 399 deletions
+178
View File
@@ -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
@@ -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,需要记录原因和下一步动作。
+2 -2
View File
@@ -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
View File
@@ -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=
+7 -2
View File
@@ -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,
+8
View File
@@ -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,174 @@
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 TestConfigUpdateRejectsSensitiveKeys(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)
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
}
func TestConfigUpdateSingleRejectsSensitiveKeys(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)
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
}
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)
}
}
+61 -6
View File
@@ -136,6 +136,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
}
h.metrics.RecordNodeMetric(nodeID, metricInfo)
})
h.wsServer.SetUserAuthStateLookup(h.GetUserAuthState)
return h
}
@@ -143,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)
@@ -152,6 +157,7 @@ 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)
@@ -334,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
}
@@ -342,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
@@ -380,7 +399,12 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
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
@@ -557,10 +581,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 := ""
@@ -948,6 +989,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if key == "" {
continue
}
if repo.IsSensitiveConfigKey(key) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if protectedKeys[key] && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
@@ -985,6 +1030,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if repo.IsSensitiveConfigKey(name) {
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" {
@@ -1255,7 +1304,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
}
@@ -1270,7 +1320,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
}
+12 -2
View File
@@ -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
}
@@ -31,6 +31,10 @@ const (
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
@@ -199,7 +203,7 @@ if [ ! -f .env ]; then
fi
log "拉取新镜像..."
if ! docker compose pull backend frontend 2>&1 | tee -a "$LOGFILE"; then
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 拉取镜像失败"
exit 1
fi
@@ -208,7 +212,7 @@ log "等待旧容器释放资源..."
sleep 3
log "重启服务(force-recreate)..."
if ! docker compose up -d --force-recreate --remove-orphans backend frontend 2>&1 | tee -a "$LOGFILE"; then
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 重启服务失败"
exit 1
fi
@@ -317,6 +321,68 @@ func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
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 {
@@ -367,9 +433,9 @@ func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helper
}
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
url := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
url := h.buildSystemUpgradeDownloadURL(version, filename)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
resp, err := systemUpgradeHTTPGet(client, url)
if err != nil {
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
}
@@ -457,7 +523,7 @@ func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
current := currentPanelVersion()
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
latest, err := resolveLatestReleaseByChannel(channel)
latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel)
response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability)))
}
@@ -477,7 +543,7 @@ func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
githubReleases, err := fetchGitHubReleases(50)
githubReleases, err := h.fetchSystemUpgradeReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
@@ -514,22 +580,21 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
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()))
@@ -3,6 +3,7 @@ package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -10,6 +11,9 @@ import (
"reflect"
"strings"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
@@ -21,19 +25,39 @@ func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
}
}
func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte("services:\n backend:\n image: test\n"))
}))
defer server.Close()
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 = server.URL
systemUpgradeReleaseBaseURL = "https://example.invalid"
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
h := &Handler{}
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)
@@ -42,24 +66,39 @@ func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) {
t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data))
}
wantPath := "/" + githubRepo + "/releases/download/2.1.9/docker-compose-v4.yml"
if gotPath != wantPath {
t.Fatalf("download path = %q, want %q", gotPath, wantPath)
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) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))
}))
defer server.Close()
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 = server.URL
systemUpgradeReleaseBaseURL = "https://example.invalid"
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
h := &Handler{}
_, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
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)
}
+39 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+39
View File
@@ -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)
}
}
+15 -14
View File
@@ -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")
}
}
+74 -27
View File
@@ -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"
)
@@ -416,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)
@@ -486,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
}
@@ -1890,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
}
@@ -1970,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
}
@@ -2352,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 {
@@ -2415,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 {
@@ -2726,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)
}
}
@@ -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
+178 -37
View File
@@ -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
+215
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+7 -2
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+4 -3
View File
@@ -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,
},
},
}
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+2 -2
View File
@@ -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,
+34 -9
View File
@@ -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
}
+3 -3
View File
@@ -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")
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
type quicConn struct {
quic.Stream
*quic.Stream
laddr net.Addr
raddr net.Addr
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
type quicConn struct {
quic.Stream
*quic.Stream
laddr net.Addr
raddr net.Addr
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm config set onlyBuiltDependencies '["@tailwindcss/oxide"]' && pnpm install --frozen-lockfile
RUN corepack prepare pnpm@10 --activate && corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
+7 -3
View File
@@ -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"
}
}
}
+25 -33
View File
@@ -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:
+2
View File
@@ -253,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) =>
+114 -44
View File
@@ -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(
+2 -2
View File
@@ -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);