mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
30 Commits
2.1.3-beta8
...
2.1.4-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 17f8a06704 | |||
| e7b777890e | |||
| 5b03ce87ff | |||
| 2aebb9ed5e | |||
| e209fc689a | |||
| f7bcb13f75 | |||
| 3d1a8c8963 | |||
| d82c099c7f | |||
| 2dfcad6154 | |||
| ba7e3c9893 | |||
| d4622903b2 | |||
| 66be07750f | |||
| a982c663d2 | |||
| 98b4d78b4d | |||
| 45d7970177 | |||
| 1b4500202a | |||
| 9a9e83dda0 | |||
| e5e22baf43 | |||
| 961c06655a | |||
| 8dc31383e0 | |||
| 184ac3c3e5 | |||
| 77dbd719ed | |||
| 04ce125416 | |||
| fd5cfc2a40 | |||
| 4e4193e0b0 | |||
| 3f80278dd4 | |||
| 6abe3e7713 | |||
| 47c05c3d02 | |||
| d05c8a2ea4 | |||
| e00e41bb64 |
@@ -48,6 +48,43 @@ jobs:
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
backend-postgres-contract:
|
||||
name: Go Backend PostgreSQL Contract
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_USER: flux_test
|
||||
POSTGRES_PASSWORD: flux_test_pass
|
||||
POSTGRES_DB: flux_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U flux_test -d flux_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go-backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache-dependency-path: go-backend/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run PostgreSQL contract test
|
||||
env:
|
||||
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable'
|
||||
run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1
|
||||
|
||||
agent:
|
||||
name: Build Agent
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
**Commit:** 3799729
|
||||
**Branch:** (detached)
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Commit:** e5e22ba
|
||||
**Branch:** main
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
@@ -12,7 +12,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
./
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── go-backend/ # Go Admin API (SQLite, net/http)
|
||||
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
|
||||
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
|
||||
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
|
||||
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
|
||||
@@ -43,14 +43,16 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
|
||||
|
||||
## CONVENTIONS
|
||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
|
||||
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
|
||||
- **DO NOT USE** ORM in backend - uses raw SQL with `database/sql`.
|
||||
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
|
||||
## COMMANDS
|
||||
@@ -69,9 +71,19 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
(cd go-gost && go run .)
|
||||
```
|
||||
|
||||
## UNIQUE STYLES
|
||||
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
|
||||
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
|
||||
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
|
||||
- CI uses UPX compression on Go binaries before release.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
|
||||
- CI uses UPX compression (`--best --lzma`) on Go binaries before release.
|
||||
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
|
||||
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
|
||||
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure (Vitest/Jest not configured).
|
||||
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
|
||||
|
||||
+25
-9
@@ -1,8 +1,8 @@
|
||||
# GO BACKEND KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Go-based Admin API for FLVX (formerly Flux Panel). Replaces the legacy Spring Boot backend.
|
||||
**Stack:** Go 1.23, net/http (std lib), SQLite (modernc.org/sqlite).
|
||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
||||
**Stack:** Go 1.23, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -14,9 +14,14 @@ go-backend/
|
||||
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
|
||||
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
|
||||
│ │ └── response/ # JSON response helpers
|
||||
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
|
||||
│ │ ├── repository.go # SQL queries & Struct definitions
|
||||
│ │ └── sql/ # Embedded schema.sql & data.sql
|
||||
│ ├── store/
|
||||
│ │ ├── model/model.go # GORM model structs (single source of truth)
|
||||
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
|
||||
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate
|
||||
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD)
|
||||
│ │ ├── repository_federation.go# Federation-specific queries
|
||||
│ │ ├── repository_flow.go # Flow/forward status queries
|
||||
│ │ └── repository_control.go # Control plane queries
|
||||
│ └── auth/ # Auth logic
|
||||
├── tests/ # Integration/Contract tests
|
||||
├── Dockerfile # Multi-stage build (alpine)
|
||||
@@ -27,21 +32,32 @@ go-backend/
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
|
||||
| **DB Schema** | `go-backend/internal/store/sqlite/sql/schema.sql` | Embedded in binary |
|
||||
| **SQL Queries** | `go-backend/internal/store/sqlite/repository.go` | Raw SQL, no ORM |
|
||||
| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |
|
||||
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
|
||||
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
|
||||
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
|
||||
|
||||
## CONVENTIONS
|
||||
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
|
||||
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
|
||||
- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.
|
||||
- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.
|
||||
- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.
|
||||
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
|
||||
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
|
||||
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
|
||||
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
|
||||
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT CHANGE** handler signatures without updating `router.go`.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go run ./cmd/paneld
|
||||
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
|
||||
go test ./...
|
||||
make build
|
||||
```
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
# 数据库 GORM ORM 迁移计划
|
||||
|
||||
**创建时间:** 2026-02-15
|
||||
**更新时间:** 2026-02-17 (实施:完成 P1 + P2 + P3 + P5(Repo 查询层 + schema 收尾) + 测试/构建收尾)
|
||||
**分支:** main (commit e5e22ba)
|
||||
**状态:** 基本完成(保留 4 处 PG 序列修复 DDL `Exec`)
|
||||
|
||||
---
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
### 1.1 迁移前架构 (已归档)
|
||||
|
||||
项目原使用 `database/sql` + 手写 raw SQL,通过 `internal/store/db.go` 中的运行时 SQL 重写层实现 SQLite/PostgreSQL 双数据库兼容。
|
||||
|
||||
| 组件 | 行数 | 角色 | 当前状态 |
|
||||
|------|------|------|----------|
|
||||
| `store/db.go` | ~520 | SQL 方言重写层 | **已删除** |
|
||||
| `store/sqlite/repository.go` | ~3118 | Repository 查询方法 | **已重写为 store/repo/** |
|
||||
| `handler/mutations.go` | ~3748 | Handler 内直接写 raw SQL | **已迁移到 repo(生产 SQL=0)** |
|
||||
| `handler/handler.go` | ~1283 | 部分方法用 `repo.DB()` | **大部分已迁移** |
|
||||
| `handler/federation.go` | ~若干 | Federation 相关 SQL | **已迁移到 repo** |
|
||||
| `handler/control_plane.go` | ~若干 | 控制面相关 SQL | **已迁移到 repo** |
|
||||
| `handler/flow_policy.go` | ~若干 | 流量策略相关 SQL | **已迁移到 repo** |
|
||||
| `handler/jobs.go` | ~若干 | 后台任务相关 SQL | **已迁移到 repo** |
|
||||
| `store/postgres/` | 目录 | PostgreSQL 专用 schema/data | **已删除** |
|
||||
|
||||
### 1.2 痛点 (迁移目标)
|
||||
|
||||
1. ~~**双 Schema 维护**~~:已通过 AutoMigrate 解决
|
||||
2. ~~**SQL 重写层复杂**~~:db.go 已删除
|
||||
3. ~~**handler 直接写 SQL**~~:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` 已清零(测试代码除外)
|
||||
4. ~~**无类型安全**~~:repo 业务查询已 GORM 化;剩余 4 处为 PG 序列修复 DDL `Exec`(设计保留)
|
||||
5. ~~**模型定义分散**~~:已集中到 model/model.go
|
||||
|
||||
---
|
||||
|
||||
## 二、方案:引入 GORM ORM(全面重写)
|
||||
|
||||
### 2.1 方案变更说明
|
||||
|
||||
原计划为 **方案 D(扩展现有 DDL 重写层)**,现变更为 **方案 A(GORM 全面重写)**。
|
||||
|
||||
### 2.2 选择 GORM 的理由
|
||||
|
||||
1. Go 生态最成熟的 ORM,社区庞大,文档完善
|
||||
2. 原生支持 SQLite + PostgreSQL 双数据库,自动处理方言差异
|
||||
3. AutoMigrate 消除双 schema 维护,自动处理 AUTOINCREMENT ↔ SERIAL 等
|
||||
4. 类型安全的模型定义,编译期检查字段映射
|
||||
5. 内置事务管理(closure pattern 自动 rollback/commit)
|
||||
6. 自动处理 `"user"` 保留字引号
|
||||
|
||||
### 2.3 GORM 驱动选择
|
||||
|
||||
| 数据库 | 驱动 | 包 | 备注 |
|
||||
|--------|------|-----|------|
|
||||
| SQLite | modernc.org/sqlite (CGO-free) | `github.com/glebarez/sqlite` | 纯 Go,无需 CGO |
|
||||
| PostgreSQL | pgx/v5 | `gorm.io/driver/postgres` | 默认使用 pgx |
|
||||
|
||||
> **注意**:标准 `gorm.io/driver/sqlite` 依赖 CGO,必须使用 `glebarez/sqlite` 包装器。
|
||||
|
||||
### 2.4 核心设计原则
|
||||
|
||||
1. **Model 集中定义**:所有 GORM Model 在 `internal/store/model/` 包中
|
||||
2. **Repository 模式保留**:Repository struct 持有 `*gorm.DB`,对外方法签名尽量不变
|
||||
3. **Handler 不直接操作 DB**:所有数据库操作必须封装在 Repository 方法中
|
||||
4. **AutoMigrate 替代 schema.sql**:启动时自动迁移,不再维护手写 DDL
|
||||
5. **保留 PG 序列修复**:pgloader 迁移场景仍需 `ensurePostgresIDDefaults()`
|
||||
6. **Package 重命名**:`store/sqlite` → `store/repo`
|
||||
|
||||
---
|
||||
|
||||
## 三、Model 设计
|
||||
|
||||
### 3.1 GORM 类型映射
|
||||
|
||||
| Go 类型 | GORM 行为 | PostgreSQL | SQLite |
|
||||
|---------|-----------|------------|--------|
|
||||
| `int64` + `primaryKey` | 自增主键 | `bigserial` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
||||
| `int64` | 64位整数 | `bigint` | `integer` (SQLite 自动 64位) |
|
||||
| `int` | 整数 | `integer` | `integer` |
|
||||
| `float64` | 浮点 | `double precision` | `real` |
|
||||
| `string` + `size:100` | 变长字符 | `varchar(100)` | `varchar(100)` |
|
||||
| `string` (无 size) | 文本 | `text` | `text` |
|
||||
| `sql.NullInt64` | 可空整数 | `bigint NULL` | `integer NULL` |
|
||||
| `sql.NullString` | 可空文本 | `text NULL` | `text NULL` |
|
||||
|
||||
### 3.2 表清单(21 张表)
|
||||
|
||||
| 表名 | Model | 特殊处理 |
|
||||
|------|-------|----------|
|
||||
| `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) |
|
||||
| `forward` | `Forward` | |
|
||||
| `forward_port` | `ForwardPort` | |
|
||||
| `node` | `Node` | |
|
||||
| `speed_limit` | `SpeedLimit` | |
|
||||
| `statistics_flow` | `StatisticsFlow` | |
|
||||
| `tunnel` | `Tunnel` | |
|
||||
| `chain_tunnel` | `ChainTunnel` | |
|
||||
| `user_tunnel` | `UserTunnel` | 复合唯一索引 (user_id, tunnel_id) |
|
||||
| `tunnel_group` | `TunnelGroup` | |
|
||||
| `user_group` | `UserGroup` | |
|
||||
| `tunnel_group_tunnel` | `TunnelGroupTunnel` | 复合唯一索引 |
|
||||
| `user_group_user` | `UserGroupUser` | 复合唯一索引 |
|
||||
| `group_permission` | `GroupPermission` | 复合唯一索引 |
|
||||
| `group_permission_grant` | `GroupPermissionGrant` | 复合唯一索引 |
|
||||
| `vite_config` | `ViteConfig` | name 唯一 |
|
||||
| `peer_share` | `PeerShare` | token 唯一 |
|
||||
| `peer_share_runtime` | `PeerShareRuntime` | reservation_id, resource_key 唯一 |
|
||||
| `federation_tunnel_binding` | `FederationTunnelBinding` | 复合唯一索引 + resource_key 唯一 |
|
||||
| `announcement` | `Announcement` | |
|
||||
| `schema_version` | `SchemaVersion` | |
|
||||
|
||||
---
|
||||
|
||||
## 四、详细实施步骤
|
||||
|
||||
### 阶段 1:基础设施 — 添加依赖 + 定义 Model ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 1.1 | `go get gorm.io/gorm gorm.io/driver/postgres github.com/glebarez/sqlite` | `go.mod` | ✅ |
|
||||
| 1.2 | 创建 `internal/store/model/model.go`,定义全部 21 个表 Model | 新文件 | ✅ |
|
||||
| 1.3 | 为 `user` 表添加 `TableName()` 处理 PG 保留字 | model.go | ✅ |
|
||||
| 1.4 | 为复合唯一索引的表添加 GORM 索引 tag | model.go | ✅ |
|
||||
| 1.5 | 将 Backup 相关 struct 也迁移到 model/ | model.go | ✅ |
|
||||
| 1.6 | 验证 `go build ./...` 编译通过 | - | ✅ |
|
||||
|
||||
### 阶段 2:GORM DB 初始化 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 2.1 | 修改 Repository struct,`*store.DB` → `*gorm.DB` | repository.go | ✅ |
|
||||
| 2.2 | 重写 `Open()` — 用 `glebarez/sqlite` 打开 SQLite | repository.go | ✅ |
|
||||
| 2.3 | 重写 `OpenPostgres()` — 用 `gorm.io/driver/postgres` 打开 PG | repository.go | ✅ |
|
||||
| 2.4 | 用 `db.AutoMigrate()` 替代 `bootstrapSchema()` | repository.go | ✅ |
|
||||
| 2.5 | 实现种子数据逻辑(FirstOrCreate 替代 data.sql) | repository.go | ✅ |
|
||||
| 2.6 | 保留并适配 `ensurePostgresIDDefaults()`(用 `db.Exec()`) | repository.go | ✅ |
|
||||
| 2.7 | 保留并适配 `migrateSchema()` 增量迁移 | repository.go | ✅ |
|
||||
| 2.8 | `DB()` 方法返回 `*gorm.DB` | repository.go | ✅ |
|
||||
| 2.9 | SQLite 连接池设置 `MaxOpenConns(1)` 防锁 | repository.go | ✅ |
|
||||
|
||||
### 阶段 3:重写 repository 查询方法 ⚠️ ~97% 完成
|
||||
|
||||
将所有 raw SQL 查询替换为 GORM 链式调用。
|
||||
|
||||
> **2026-02-16 审计**:基础 CRUD 查询已 GORM 化,但 mutation、JOIN 查询、import/export 仍大量使用 raw SQL。
|
||||
> **2026-02-17 更新**:已完成 `repository_mutations.go`、Import、以及 `repository_federation/control/flow` 查询层 GORM 化;`repository.go` 中 Raw 已清零,当前仅保留 4 处 PG 序列修复 DDL `Exec`。
|
||||
|
||||
| 步骤 | 任务 | 方法数 | 状态 |
|
||||
|------|------|--------|------|
|
||||
| 3.1 | 用户查询:GetUserByUsername, GetUserByID, UsernameExists* 等 | ~5 | ✅ |
|
||||
| 3.2 | 配置查询:GetConfigByName, ListConfigs, UpsertConfig | ~3 | ✅ |
|
||||
| 3.3 | 公告查询:GetAnnouncement, UpsertAnnouncement | ~2 | ✅ |
|
||||
| 3.4 | 节点查询:GetNodeBy*, ListNodes, UpdateNode* | ~6 | ✅ |
|
||||
| 3.5 | 隧道查询:ListTunnels, ListTunnelGroups 等 (含 chain_tunnel 关联) | ~5 | ✅ |
|
||||
| 3.6 | 转发查询:ListForwards, resolveForwardIngress | ~3 | ✅ |
|
||||
| 3.7 | 用户隧道:GetUserPackageTunnels, GetUserPackageForwards | ~3 | ✅ |
|
||||
| 3.8 | 统计/限速:GetStatisticsFlows, ListSpeedLimits, AddFlow | ~4 | ✅ |
|
||||
| 3.9 | 分组查询:ListUserGroups, ListGroupPermissions 等 | ~4 | ✅ |
|
||||
| 3.10 | PeerShare 全部方法 (CRUD + Runtime) | ~15 | ✅ |
|
||||
| 3.11 | FederationTunnelBinding 全部方法 | ~4 | ✅ (Upsert 用 clause.OnConflict) |
|
||||
| 3.12 | Export 全部方法 | ~10 | ✅ |
|
||||
| 3.13 | Import 全部方法 | ~10 | ✅ 已全部改为 GORM `Clauses(clause.OnConflict)`(见 §9.6) |
|
||||
| **3.14** | **repository_mutations.go 全部方法 (~40 个)** | **~40** | **✅ 已全量改为 GORM 链式调用(见 §9.3)** |
|
||||
| **3.15** | **repository_federation.go 查询方法** | **~8** | **✅ 已全部改为 GORM 链式调用** |
|
||||
| **3.16** | **repository_control.go 复杂查询** | **~5** | **✅ 已全部改为 GORM 链式调用** |
|
||||
| **3.17** | **repository_flow.go 查询方法** | **~5** | **✅ 已全部改为 GORM 链式调用** |
|
||||
| **3.18** | **Jobs 查询方法 (repository.go 尾部)** | **~8** | **✅ 已 GORM 化** |
|
||||
|
||||
### 阶段 4:消除 handler 中直接 SQL — 提取为 Repository 方法 ✅ 已完成
|
||||
|
||||
> **2026-02-16 审计**:handler 中的 SQL 已大部分提取到 repo 层,但这些 repo 方法本身仍使用 raw SQL(见阶段 3)。
|
||||
> **2026-02-17 更新**:`mutations.go` 直接 `tx.Exec`/`tx.Raw` 已从 27 处降至 0 处(生产代码),详见 §9.4。
|
||||
|
||||
mutations.go 和其他 handler 文件中大量直接操作 `h.repo.DB()` 执行 raw SQL,需要:
|
||||
1. 将 SQL 逻辑提取为 Repository 方法
|
||||
2. Handler 只调用 Repository 方法
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 4.1 | 用户 CRUD:userCreate, userUpdate, userDelete, userResetFlow | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.2 | 节点 CRUD:nodeCreate, nodeUpdate, nodeDelete, nodeBatch* | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.3 | 隧道 CRUD:tunnelCreate, tunnelUpdate, tunnelDelete, tunnelBatch* | mutations.go | ✅ tunnelCreate/Update 的 SQL 已下沉 repo |
|
||||
| 4.4 | 转发 CRUD:forwardCreate, forwardUpdate, forwardDelete, forwardBatch* | mutations.go | ✅ 已提取到 repo (CreateForwardTx 等) |
|
||||
| 4.5 | 限速 CRUD:speedLimitCreate, speedLimitUpdate, speedLimitDelete | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.6 | 分组 CRUD:所有 group* 方法 | mutations.go | ✅ 成员同步/权限管理 SQL 已下沉 repo |
|
||||
| 4.7 | 用户隧道:userTunnelAssign, userTunnelRemove, userTunnelUpdate | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.8 | handler.go 中的直接 SQL (openAPISubStore 等) | handler.go | ✅ 已迁移(含 nil 检查清理) |
|
||||
| 4.9 | federation.go 中的 raw SQL | federation.go | ✅ 已提取到 repo_federation.go |
|
||||
| 4.10 | control_plane.go 中的 raw SQL | control_plane.go | ✅ 已提取到 repo_control.go |
|
||||
| 4.11 | flow_policy.go 中的 raw SQL | flow_policy.go | ✅ 已提取到 repo_flow.go |
|
||||
| 4.12 | jobs.go 中的 raw SQL | jobs.go | ✅ 已提取到 repo 方法(含 nil 检查清理) |
|
||||
|
||||
### 阶段 5:清理旧代码 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 5.1 | 删除 `internal/store/postgres/` 整个目录 | 目录删除 | ✅ |
|
||||
| 5.2 | 删除 `internal/store/sqlite/sql/` 目录 | 目录删除 | ✅ |
|
||||
| 5.3 | 删除 `internal/store/db.go` SQL 重写层 | 文件删除 | ✅ |
|
||||
| 5.4 | 删除 `internal/store/db_test.go` | 文件删除 | ✅ |
|
||||
| 5.5 | 清理 repository.go 中不再需要的 embed 指令 | 清理 | ✅ |
|
||||
|
||||
### 阶段 6:Package 重命名 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 6.1 | `internal/store/sqlite/` → `internal/store/repo/` | 目录重命名 | ✅ |
|
||||
| 6.2 | 更新所有 import 路径:`store/sqlite` → `store/repo` (13处) | 全局替换 | ✅ |
|
||||
|
||||
### 阶段 7:测试 + 验证 ⚠️ 部分完成
|
||||
|
||||
| 步骤 | 任务 | 状态 |
|
||||
|------|------|------|
|
||||
| 7.1 | 更新所有现有测试适配 GORM | ✅ 测试已适配 (使用 repo.DB() 做数据准备) |
|
||||
| 7.2 | `go test ./...` 全部通过 | ✅ 已通过(含 `internal/http/handler`、`tests/contract`) |
|
||||
| 7.3 | `make build` 构建成功 | ✅ 已通过 |
|
||||
|
||||
### 阶段 8:文档更新 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 8.1 | 更新 `go-backend/AGENTS.md` — 移除 "DO NOT USE ORM",记录 GORM 规范 | AGENTS.md | ✅ |
|
||||
| 8.2 | 更新根 `AGENTS.md` | AGENTS.md | ✅ |
|
||||
| 8.3 | 更新 `handler/AGENTS.md` | AGENTS.md | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 五、GORM 使用规范
|
||||
|
||||
### 5.1 查询模式
|
||||
|
||||
```go
|
||||
// 单条查询 - 未找到返回 nil, nil (保持现有语义)
|
||||
var user model.User
|
||||
err := r.db.Where("id = ?", id).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
var users []model.User
|
||||
err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error
|
||||
|
||||
// 创建
|
||||
err := r.db.Create(&user).Error
|
||||
|
||||
// 更新 (部分字段)
|
||||
err := r.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"user": username, "flow": flow, "updated_time": now,
|
||||
}).Error
|
||||
|
||||
// 事务 (closure pattern - 自动 rollback/commit)
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", id).Delete(&model.Forward{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Delete(&model.User{}).Error
|
||||
})
|
||||
|
||||
// 原生 SQL (仅用于复杂查询和 PG 特有操作)
|
||||
r.db.Exec("SELECT setval(?::regclass, ?, ?)", seqRef, maxID, true)
|
||||
```
|
||||
|
||||
### 5.2 关键注意事项
|
||||
|
||||
1. **user 保留字**:通过 `TableName()` 返回 `"user"`,GORM 自动处理引号
|
||||
2. **SQLite MaxOpenConns**:必须设为 1 防止 "database locked"
|
||||
3. **SQLite WAL 模式**:DSN 中配置 `_pragma=journal_mode(WAL)`
|
||||
4. **不要用 `type:jsonb`**:SQLite 不支持,用 `serializer:json`
|
||||
5. **不要用 `type:serial`**:让 GORM 从 `primaryKey` 自动推断
|
||||
6. **AutoMigrate 在 SQLite 中使用 copy-swap-drop**:大表慎用
|
||||
|
||||
---
|
||||
|
||||
## 六、影响范围
|
||||
|
||||
### 需要修改的文件
|
||||
|
||||
| 文件 | 修改类型 | 描述 | 当前状态 |
|
||||
|------|----------|------|----------|
|
||||
| `go.mod` / `go.sum` | 修改 | 添加 GORM + 驱动依赖 | ✅ |
|
||||
| `internal/store/model/model.go` | **新增** | 全部 21 个 GORM Model | ✅ |
|
||||
| `internal/store/repo/repository.go` | **重写** | 全部查询 GORM 化 | ⚠️ 业务查询已 GORM;仅剩 PG 序列修复 DDL `Exec` 4 处 |
|
||||
| `internal/store/repo/repository_mutations.go` | **重写** | Mutation helpers | ✅ 全量 GORM(Raw=0) |
|
||||
| `internal/store/repo/repository_federation.go` | **重写** | Federation 查询 | ✅ 已 GORM 化(Raw=0) |
|
||||
| `internal/store/repo/repository_control.go` | **重写** | 控制面查询 | ✅ 已 GORM 化(Raw=0) |
|
||||
| `internal/store/repo/repository_flow.go` | **重写** | 流量/转发查询 | ✅ 已 GORM 化(Raw=0) |
|
||||
| `internal/http/handler/mutations.go` | **重写** | 全部 CRUD 提取到 repo | ✅ 生产代码 `tx.Exec/tx.Raw` = 0 |
|
||||
| `internal/http/handler/handler.go` | 修改 | 更新 import、移除直接 SQL | ✅ (仅剩 nil check) |
|
||||
| `internal/http/handler/federation.go` | 修改 | GORM 替代 raw SQL | ✅ |
|
||||
| `internal/http/handler/control_plane.go` | 修改 | GORM 替代 raw SQL | ✅ |
|
||||
| `internal/http/handler/flow_policy.go` | 修改 | GORM 替代 raw SQL | ✅ |
|
||||
| `internal/http/handler/jobs.go` | 修改 | GORM 替代 raw SQL | ✅ (仅剩 nil check) |
|
||||
| `internal/ws/server.go` | 修改 | 更新 import | ✅ |
|
||||
| `internal/app/app.go` | 修改 | 更新 import | ✅ |
|
||||
| `internal/store/postgres/` | **删除** | 不再需要 | ✅ |
|
||||
| `internal/store/db.go` | **删除** | GORM 自动处理方言 | ✅ |
|
||||
| `internal/store/db_test.go` | **删除** | 旧重写层测试 | ✅ |
|
||||
| `internal/store/sqlite/sql/` | **删除** | AutoMigrate 替代 | ✅ |
|
||||
| `tests/contract/*.go` | 修改 | 适配 GORM | ✅ |
|
||||
| `AGENTS.md` (3处) | 更新 | 反映新架构 | ✅ |
|
||||
|
||||
### 不需要修改的文件
|
||||
|
||||
- `internal/http/router.go` — 路由不变
|
||||
- `internal/config/config.go` — 配置不变
|
||||
- `internal/auth/` — 认证不变
|
||||
- `internal/security/` — 加密不变
|
||||
- `internal/http/middleware/` — 中间件不变
|
||||
- `internal/http/response/` — 响应格式不变
|
||||
- `Dockerfile`, `Makefile` — 构建不变
|
||||
|
||||
---
|
||||
|
||||
## 七、风险与缓解
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|----------|
|
||||
| GORM AutoMigrate SQLite/PG 行为差异 | 中 | 高 | 先写 Model 验证双数据库 AutoMigrate |
|
||||
| handler 中散落 raw SQL 遗漏 | 中 | 高 | 全局搜索 `.Exec(`, `.Query(`, `.QueryRow(` |
|
||||
| 事务语义变化 | 低 | 中 | 逐方法对比旧代码事务边界 |
|
||||
| 大量代码变更导致回归 | 高 | 高 | 分阶段提交,每阶段 `go test` |
|
||||
| GORM 性能开销 | 低 | 低 | 此场景下可忽略 |
|
||||
| SQLite "database locked" | 中 | 高 | `MaxOpenConns(1)` + WAL 模式 |
|
||||
|
||||
---
|
||||
|
||||
## 八、迁移顺序原则
|
||||
|
||||
1. **先 Model 后查询**:确保 AutoMigrate 双数据库通过
|
||||
2. **先 Repository 后 Handler**:Handler 依赖 Repository
|
||||
3. **先核心后边缘**:User → Node → Tunnel → Forward → 分组 → Federation
|
||||
4. **每步编译**:每完成一组方法确保 `go build ./...` 通过
|
||||
5. **最后清理**:全部重写完成后再删除旧代码和重命名 package
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 九、2026-02-16 审计发现 + 2026-02-17 进展记录
|
||||
|
||||
### 9.1 总体完成度
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 阶段完成数 | 7/8 完成 (1, 2, 4, 5, 6, 7, 8),1/8 部分完成 (3) |
|
||||
| GORM 链式调用 | ~226 处 |
|
||||
| Raw SQL 调用 (`.Exec`/`.Raw`+`.Scan`) | 4 处(生产代码) |
|
||||
| GORM 占比 | ~98% |
|
||||
| Handler 内 `tx.Exec`/`tx.Raw` | 0 处(生产代码) |
|
||||
| `last_insert_rowid()` 生产代码 | 0 处(已消灭) |
|
||||
|
||||
### 9.2 ✅ P0:`last_insert_rowid()`(生产代码)已清零
|
||||
|
||||
`last_insert_rowid()` 已从生产路径移除,创建主键统一改为 `Create(&model)` 自动回填 ID,
|
||||
确保 SQLite / PostgreSQL 双数据库行为一致。
|
||||
|
||||
> 备注:测试代码中的历史 SQL 兼容性用例可在后续测试清理阶段单独处理。
|
||||
|
||||
### 9.3 ✅ P1:`repository_mutations.go` 已全量 GORM 化
|
||||
|
||||
本次已完成 `repository_mutations.go` 的集中清理:
|
||||
|
||||
1. User / Node / Tunnel / Forward / UserTunnel / SpeedLimit / Group / Permission 全部 mutation 方法改为 GORM 链式调用。
|
||||
2. 事务内级联删除统一为 `tx.Where(...).Delete(&Model{})` 模式。
|
||||
3. `ON CONFLICT DO NOTHING` 统一替换为 `Clauses(clause.OnConflict{DoNothing: true})`。
|
||||
4. 保留原有调用语义(含 `sql.ErrNoRows` 行为兼容)并完成 `go build ./...` 验证。
|
||||
|
||||
> 当前 `repository_mutations.go` 中生产代码 `.Raw(`/`.Exec(` 调用已降为 0。
|
||||
|
||||
### 9.4 ✅ P2:Handler `mutations.go` 直接 SQL 已清零
|
||||
|
||||
2026-02-17 本轮静态扫描结果:`mutations.go` **0 处** `tx.Exec`/`tx.Raw`(生产代码)。
|
||||
|
||||
本轮完成下沉到 repo 的逻辑:
|
||||
|
||||
- `tunnelUpdate` 中 `UPDATE tunnel` + `DELETE chain_tunnel`
|
||||
- `isRemoteNodeTx` 查询
|
||||
- `pickNodePortTx` 的 node/chain_tunnel/forward_port 端口占用查询
|
||||
- `replaceTunnelChainsTx` 的 chain_tunnel 写入
|
||||
- 分组成员同步(`tunnel_group_tunnel` / `user_group_user`)
|
||||
- 权限删除与 grant 回收(`group_permission` / `group_permission_grant` / `user_tunnel`)
|
||||
- federation 绑定替换(`federation_tunnel_binding`)
|
||||
|
||||
### 9.5 ✅ P3(部分):已移除 `QueryInt64List` / `QueryPairs` SQL 透传
|
||||
|
||||
- `repository_mutations.go` 中两个 SQL 透传入口已删除。
|
||||
- Handler 已切换为语义化 repo 方法:
|
||||
- `ListUserIDsByUserGroup`
|
||||
- `ListTunnelIDsByTunnelGroup`
|
||||
- `ListGroupPermissionPairsByUserGroup`
|
||||
- `ListGroupPermissionPairsByTunnelGroup`
|
||||
|
||||
### 9.6 ✅ P3:Import 函数已全部 GORM 化
|
||||
|
||||
`repository.go` 中 Import 相关函数已完成迁移:
|
||||
|
||||
- `importUsers`
|
||||
- `importNodes`
|
||||
- `importTunnels`(含 `chain_tunnel` 子项 upsert)
|
||||
- `importForwards`(含 `forward_port` 覆盖写入)
|
||||
- `importUserTunnels`
|
||||
- `importSpeedLimits`
|
||||
- `importTunnelGroups`
|
||||
- `importUserGroups`
|
||||
- `importPermissions`
|
||||
- `importConfigs`(原本已是 GORM)
|
||||
|
||||
迁移后统一采用 `Clauses(clause.OnConflict{Columns: id/name, DoUpdates: ...}).Create(&model)` 模式,
|
||||
保留原 `ON CONFLICT ... DO UPDATE` 语义;Import 区段 `tx.Exec`/`tx.Raw` 已清零。
|
||||
|
||||
### 9.7 ✅ P4:`h.repo.DB() == nil` 检查已清理
|
||||
|
||||
`internal/http/handler/` 下已无 `h.repo.DB()` 直接访问;handler 仅通过语义化 repo 方法进行数据访问。
|
||||
|
||||
### 9.8 ✅ P5:Repository 层 Raw 已收敛(仅保留 PG 序列修复 DDL)
|
||||
|
||||
当前生产代码中 `.Raw()` 已清零;仅剩 `repository.go` 的 4 处 `Exec()`,全部位于 PG 序列修复 DDL:
|
||||
|
||||
- `CREATE SEQUENCE IF NOT EXISTS ...`
|
||||
- `ALTER TABLE ... ALTER COLUMN id SET DEFAULT nextval(...)`
|
||||
- `ALTER SEQUENCE ... OWNED BY ...`
|
||||
- `SELECT setval(...::regclass, ?, ?)`
|
||||
|
||||
以上 4 处属于数据库管理 DDL/序列同步语义,当前保留,不再继续向 GORM 链式调用替换。
|
||||
|
||||
`repository_federation.go` / `repository_control.go` / `repository_flow.go` 已完成 GORM 化(Raw=0)。
|
||||
|
||||
---
|
||||
|
||||
## 十、后续工作优先级
|
||||
|
||||
| 优先级 | 任务 | 影响范围 | 工作量 |
|
||||
|--------|------|----------|--------|
|
||||
| **P0** | ✅ 已完成:生产代码中 `last_insert_rowid()` 清零(测试用例待单独清理) | 6 处生产(已完成) | 完成 |
|
||||
| **P1** | ✅ 已完成:`repository_mutations.go` ~40 方法改为 GORM 链式调用 | 659 行(已完成) | 完成 |
|
||||
| **P2** | ✅ 已完成:`mutations.go` handler 直接 SQL 全部提取为 repo 方法 | mutations.go | 完成 |
|
||||
| **P3** | ✅ 已完成:移除 `QueryInt64List`/`QueryPairs` 透传,切换语义化 repo 方法 | 2 个方法 + 调用方(已完成) | 完成 |
|
||||
| **P3** | ✅ 已完成:Import 函数 Raw SQL 改为 GORM `Clauses(clause.OnConflict{}).Create()` | 9 个函数(已完成) | 完成 |
|
||||
| **P4** | ✅ 已完成:`h.repo.DB() == nil` 检查清理完毕 | 4 处(已完成) | 完成 |
|
||||
| **P5** | ✅ 已完成:repo 查询层 Raw 清零,`repository.go` 保留 4 处 PG 序列修复 DDL `Exec`(设计保留) | repository.go | 完成 |
|
||||
| **P5** | ✅ 已完成:更新 MIGRATION_PLAN.md 状态标记与收尾记录 | 本文件 | 完成 |
|
||||
|
||||
### 10.5 本轮执行记录(2026-02-17,P5 schema 收尾)
|
||||
|
||||
1. 完成 `repository.go` schema 迁移段去 Raw:
|
||||
- `normalizeStrategy` 改为 `Model(...).Where(...).Update(...)`
|
||||
- `ensurePostgresIDDefaults`/`ensurePostgresTableIDDefault` 的 information_schema 查询改为 GORM `Table+Joins+Where+Scan`
|
||||
- `syncPostgresTableIDSequence` 的 `MAX(id)` 查询改为 GORM `Table+Select+Scan`
|
||||
2. 复扫结果:
|
||||
- `repository.go` `.Raw()` = 0
|
||||
- repo 生产路径剩余 `.Exec()` = 4(全部为 PG 序列修复 DDL)
|
||||
3. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.6 本轮执行记录(2026-02-17,测试/构建收尾)
|
||||
|
||||
1. 修复事务内 SQLite 连接阻塞(`MaxOpenConns(1)` 场景):
|
||||
- 新增 `GetNodeRecordTx` 并在 `prepareTunnelCreateState` 使用事务句柄读取节点。
|
||||
- 新增 `GetNodeRemoteFieldsTx` 并在 `tunnelCreate` 事务内改用事务句柄读取远端字段。
|
||||
- `applyFederationRuntime` 改为显式接收 `localDomain`,避免事务内再次走 `repo.GetConfigByName`。
|
||||
2. 修复 legacy SQLite schema 迁移契约:
|
||||
- 新增 `prepareSQLiteLegacyColumns` 预补齐 `node/tunnel` 关键列。
|
||||
- SQLite 模式下对已存在 `node/tunnel` 表跳过对应 `AutoMigrate` 重建流程,避免 `node__temp.name` 约束失败。
|
||||
3. 验证结果:
|
||||
- `go test ./internal/http/handler/...` ✅
|
||||
- `go test ./tests/contract/...` ✅
|
||||
- `go test ./...` ✅
|
||||
- `go build ./...` ✅
|
||||
- `make build` ✅
|
||||
|
||||
### 10.1 本轮执行记录(2026-02-17,P5 查询层)
|
||||
|
||||
1. 完成 `repository_federation.go` 全量 GORM 化:
|
||||
- `ListRemoteNodes` / `UpdateNodeRemoteConfig`
|
||||
- `ListActiveBindingsForNode` / `GetNodeBasicInfo`
|
||||
- `ListUsedPortsOnNode` / `ListTunnelIDsByNamePrefix` / `NextIndex`
|
||||
2. 完成 `repository_control.go` 全量 GORM 化:
|
||||
- `ListForwardsByTunnel` / `ListForwardPorts` / `GetTunnelOutProtocol`
|
||||
- `ResolveUserTunnelAndLimiter` / `ListChainNodesForTunnel`
|
||||
3. 完成 `repository_flow.go` 全量 GORM 化:
|
||||
- `ListActiveForwardsByUser` / `ListActiveForwardsByUserTunnel`
|
||||
- `GetForwardRecord` / `GetTunnelRecord`
|
||||
4. 复扫结果:
|
||||
- `repository_federation.go` Raw/Exec = 0
|
||||
- `repository_control.go` Raw/Exec = 0
|
||||
- `repository_flow.go` Raw/Exec = 0
|
||||
- repo 生产路径剩余 Raw/Exec = 9(全部在 `repository.go`)
|
||||
5. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.2 本轮执行记录(2026-02-17)
|
||||
|
||||
1. 完成 P3 Import 9 个函数的 GORM 化(`repository.go`),并保持 `ON CONFLICT` 语义一致。
|
||||
2. 复扫确认:`repository.go` Import 区段 `tx.Exec`/`tx.Raw` 已清零。
|
||||
3. 验证结果:
|
||||
- `go build ./...` ✅(使用显式 `GOMODCACHE/GOPATH/GOCACHE/HOME` 环境)
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.3 本轮执行记录(2026-02-17,P2 部分)
|
||||
|
||||
1. 将 tunnel 更新/chain 重建路径 SQL 下沉到 `repository_mutations.go`:
|
||||
- 新增 `UpdateTunnelTx`
|
||||
- 新增 `DeleteChainTunnelsByTunnelTx`
|
||||
- 新增 `CreateChainTunnelTx`
|
||||
2. 将 handler 内部 SQL helper 迁移到 repo:
|
||||
- 新增 `IsRemoteNodeTx`
|
||||
- 新增 `PickNodePortTx`
|
||||
- `replaceTunnelChainsTx` 改为 handler 方法并改用 repo 调用,不再直接 SQL
|
||||
3. 复扫结果:`mutations.go` 直接 SQL 从 27 处降至 17 处。
|
||||
4. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.4 本轮执行记录(2026-02-17,P2 收尾)
|
||||
|
||||
1. 新增并落地事务语义化 repo 方法:
|
||||
- `ReplaceTunnelGroupMembersTx` / `ReplaceUserGroupMembersTx`
|
||||
- `ListUserIDsByUserGroupTx`
|
||||
- `GetGroupPermissionPairByIDTx` / `DeleteGroupPermissionByIDTx`
|
||||
- `RevokeGroupGrantsForRemovedUsersTx` / `RevokeGroupPermissionPairTx`
|
||||
- `ReplaceFederationTunnelBindingsTx`
|
||||
2. 删除 handler 内 SQL helper(`queryInt64ListTx` / `revokeGroupGrantsForRemovedUsersTx` / `revokeGroupPermissionPairTx` / `replaceFederationTunnelBindingsTx`)。
|
||||
3. 复扫确认:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` = 0。
|
||||
4. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
---
|
||||
|
||||
*本文档将随迁移进展实时更新状态标记。*
|
||||
*最后审计时间:2026-02-17,审计工具:代码静态分析 (grep/AST) + go build/go test 验证*
|
||||
@@ -12,10 +12,14 @@ require (
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -24,6 +28,8 @@ require (
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -3,6 +3,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -17,6 +21,10 @@ github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
|
||||
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
@@ -49,6 +57,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -10,30 +10,30 @@ import (
|
||||
"go-backend/internal/config"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
server *http.Server
|
||||
repo *sqlite.Repository
|
||||
repo *repo.Repository
|
||||
h *handler.Handler
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*App, error) {
|
||||
var (
|
||||
repo *sqlite.Repository
|
||||
err error
|
||||
r *repo.Repository
|
||||
err error
|
||||
)
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.DBType)) {
|
||||
case "", "sqlite":
|
||||
repo, err = sqlite.Open(cfg.DBPath)
|
||||
r, err = repo.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
case "postgres", "postgresql":
|
||||
repo, err = sqlite.OpenPostgres(cfg.DatabaseURL)
|
||||
r, err = repo.OpenPostgres(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
|
||||
}
|
||||
|
||||
h := handler.New(repo, cfg.JWTSecret)
|
||||
h := handler.New(r, cfg.JWTSecret)
|
||||
router := httpserver.NewRouter(h, cfg.JWTSecret)
|
||||
|
||||
s := &http.Server{
|
||||
@@ -53,7 +53,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
return &App{cfg: cfg, server: s, repo: repo, h: h}, nil
|
||||
return &App{cfg: cfg, server: s, repo: r, h: h}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# BACKEND HTTP HANDLER KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
**Generated:** Sun Feb 15 2026
|
||||
|
||||
## OVERVIEW
|
||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||
**Stack:** Go 1.23, net/http, raw SQL (no ORM).
|
||||
**Stack:** Go 1.23, net/http, GORM via Repository pattern.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -28,12 +28,14 @@ handler/
|
||||
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent: raw SQL, no ORM, JWT in Authorization header.
|
||||
- Large files expected (`mutations.go` >100k LOC).
|
||||
- Uses `sqlite.Repository` for DB access via `repo.XXX()` methods.
|
||||
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
|
||||
- Large files expected (`mutations.go` 3716 LOC - central mutation hub).
|
||||
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
|
||||
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
|
||||
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do NOT add ORM here - uses raw SQL throughout.
|
||||
- Do NOT let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- Do NOT change handler signatures without updating router.go.
|
||||
|
||||
## COMMANDS
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -12,61 +11,18 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
var errForwardNotFound = errors.New("forward not found")
|
||||
|
||||
type forwardRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
}
|
||||
type forwardRecord = model.ForwardRecord
|
||||
type tunnelRecord = model.TunnelRecord
|
||||
type forwardPortRecord = model.ForwardPortRecord
|
||||
type nodeRecord = model.NodeRecord
|
||||
|
||||
type tunnelRecord struct {
|
||||
ID int64
|
||||
Type int
|
||||
Status int
|
||||
Flow int64
|
||||
TrafficRatio float64
|
||||
}
|
||||
|
||||
type forwardPortRecord struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
}
|
||||
|
||||
type nodeRecord struct {
|
||||
ID int64
|
||||
Name string
|
||||
ServerIP string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
Status int
|
||||
PortRange string
|
||||
TCPListenAddr string
|
||||
UDPListenAddr string
|
||||
InterfaceName string
|
||||
IsRemote int
|
||||
RemoteURL string
|
||||
RemoteToken string
|
||||
RemoteConfig string
|
||||
}
|
||||
|
||||
type chainNodeRecord struct {
|
||||
ChainType int
|
||||
Inx int64
|
||||
NodeID int64
|
||||
Port int
|
||||
NodeName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
}
|
||||
type chainNodeRecord = model.ChainNodeRecord
|
||||
|
||||
type diagnosisTarget struct {
|
||||
Address string
|
||||
@@ -101,247 +57,82 @@ func (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int6
|
||||
if roleID == 0 {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? AND status = 1`, userID, tunnelID).Scan(&count)
|
||||
ok, err := h.repo.UserTunnelExistsByUserAndTunnel(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count <= 0 {
|
||||
if !ok {
|
||||
return errors.New("你没有该隧道的权限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward WHERE id = ? LIMIT 1
|
||||
`, forwardID)
|
||||
var fr forwardRecord
|
||||
err := row.Scan(&fr.ID, &fr.UserID, &fr.UserName, &fr.Name, &fr.TunnelID, &fr.RemoteAddr, &fr.Strategy, &fr.Status)
|
||||
fr, err := h.repo.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errForwardNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
if fr == nil {
|
||||
return nil, errForwardNotFound
|
||||
}
|
||||
return &fr, nil
|
||||
return fr, nil
|
||||
}
|
||||
|
||||
func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {
|
||||
row := h.repo.DB().QueryRow(`SELECT id, type, status, flow, traffic_ratio FROM tunnel WHERE id = ? LIMIT 1`, tunnelID)
|
||||
var tr tunnelRecord
|
||||
err := row.Scan(&tr.ID, &tr.Type, &tr.Status, &tr.Flow, &tr.TrafficRatio)
|
||||
tr, err := h.repo.GetTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("隧道不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if tr.Flow <= 0 {
|
||||
tr.Flow = 1
|
||||
if tr == nil {
|
||||
return nil, errors.New("隧道不存在")
|
||||
}
|
||||
if tr.TrafficRatio <= 0 {
|
||||
tr.TrafficRatio = 1
|
||||
}
|
||||
return &tr, nil
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward
|
||||
WHERE tunnel_id = ?
|
||||
ORDER BY id ASC
|
||||
`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]forwardRecord, 0)
|
||||
for rows.Next() {
|
||||
var fr forwardRecord
|
||||
if err := rows.Scan(&fr.ID, &fr.UserID, &fr.UserName, &fr.Name, &fr.TunnelID, &fr.RemoteAddr, &fr.Strategy, &fr.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
}
|
||||
result = append(result, fr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
return h.repo.ListForwardsByTunnel(tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]forwardPortRecord, 0)
|
||||
for rows.Next() {
|
||||
var item forwardPortRecord
|
||||
if err := rows.Scan(&item.NodeID, &item.Port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
return h.repo.ListForwardPorts(forwardID)
|
||||
}
|
||||
|
||||
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT protocol
|
||||
FROM chain_tunnel
|
||||
WHERE tunnel_id = ? AND chain_type = '3'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`, tunnelID)
|
||||
|
||||
var protocol sql.NullString
|
||||
if err := row.Scan(&protocol); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
protocol, err := h.repo.GetTunnelOutProtocol(tunnelID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return isTLSTunnelProtocol(protocol.String), nil
|
||||
return isTLSTunnelProtocol(protocol), nil
|
||||
}
|
||||
|
||||
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name, is_remote, remote_url, remote_token, remote_config
|
||||
FROM node
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`, nodeID)
|
||||
var n nodeRecord
|
||||
var serverIPv4 sql.NullString
|
||||
var serverIPv6 sql.NullString
|
||||
var portRange sql.NullString
|
||||
var tcpListen sql.NullString
|
||||
var udpListen sql.NullString
|
||||
var iface sql.NullString
|
||||
var remoteURL sql.NullString
|
||||
var remoteToken sql.NullString
|
||||
var remoteConfig sql.NullString
|
||||
err := row.Scan(&n.ID, &n.Name, &n.ServerIP, &serverIPv4, &serverIPv6, &n.Status, &portRange, &tcpListen, &udpListen, &iface, &n.IsRemote, &remoteURL, &remoteToken, &remoteConfig)
|
||||
n, err := h.repo.GetNodeRecord(nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
n.ServerIPv4 = strings.TrimSpace(serverIPv4.String)
|
||||
n.ServerIPv6 = strings.TrimSpace(serverIPv6.String)
|
||||
n.PortRange = strings.TrimSpace(portRange.String)
|
||||
n.TCPListenAddr = strings.TrimSpace(tcpListen.String)
|
||||
n.UDPListenAddr = strings.TrimSpace(udpListen.String)
|
||||
n.InterfaceName = strings.TrimSpace(iface.String)
|
||||
n.RemoteURL = strings.TrimSpace(remoteURL.String)
|
||||
n.RemoteToken = strings.TrimSpace(remoteToken.String)
|
||||
n.RemoteConfig = strings.TrimSpace(remoteConfig.String)
|
||||
if n.TCPListenAddr == "" {
|
||||
n.TCPListenAddr = "[::]"
|
||||
if n == nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
if n.UDPListenAddr == "" {
|
||||
n.UDPListenAddr = "[::]"
|
||||
}
|
||||
if strings.TrimSpace(n.Name) == "" {
|
||||
n.Name = fmt.Sprintf("node_%d", n.ID)
|
||||
}
|
||||
return &n, nil
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT ut.id, sl.id, sl.speed
|
||||
FROM user_tunnel ut
|
||||
LEFT JOIN speed_limit sl ON sl.id = ut.speed_id
|
||||
WHERE ut.user_id = ? AND ut.tunnel_id = ?
|
||||
ORDER BY ut.id ASC
|
||||
LIMIT 1
|
||||
`, userID, tunnelID)
|
||||
var userTunnelID int64
|
||||
var limiterID sql.NullInt64
|
||||
var speed sql.NullInt64
|
||||
err := row.Scan(&userTunnelID, &limiterID, &speed)
|
||||
info, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
if !limiterID.Valid || limiterID.Int64 <= 0 {
|
||||
return userTunnelID, nil, nil, nil
|
||||
if info == nil {
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
v := limiterID.Int64
|
||||
s := int(speed.Int64)
|
||||
return userTunnelID, &v, &s, nil
|
||||
return info.UserTunnelID, info.LimiterID, info.Speed, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id
|
||||
FROM user_tunnel
|
||||
WHERE user_id = ? AND tunnel_id = ?
|
||||
ORDER BY id ASC
|
||||
`, userID, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
return h.repo.ListUserTunnelIDs(userID, tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id
|
||||
FROM user_tunnel
|
||||
WHERE user_id = ?
|
||||
ORDER BY id ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
return h.repo.ListUserTunnelIDsByUser(userID)
|
||||
}
|
||||
|
||||
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
|
||||
@@ -592,7 +383,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
})
|
||||
}, "")
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -600,7 +391,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
})
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -615,7 +406,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
})
|
||||
}, "")
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -624,7 +415,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
})
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -663,13 +454,13 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tunnelName string
|
||||
if err := h.repo.DB().QueryRow(`SELECT name FROM tunnel WHERE id = ?`, tunnelID).Scan(&tunnelName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("隧道不存在")
|
||||
}
|
||||
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tunnelName == "" {
|
||||
return nil, errors.New("隧道不存在")
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil {
|
||||
@@ -679,6 +470,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
return nil, errors.New("隧道配置不完整")
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
results := make([]map[string]interface{}, 0, len(chainRows)*2)
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
@@ -700,7 +492,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
})
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -708,7 +500,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
})
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,7 +515,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
})
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -732,7 +524,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
})
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -902,13 +694,14 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
||||
*results = append(*results, item)
|
||||
}
|
||||
|
||||
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}) {
|
||||
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string) {
|
||||
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
|
||||
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
||||
if err != nil {
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error())
|
||||
return
|
||||
}
|
||||
targetIP, targetPort, err := resolveChainProbeTarget(targetNode, toNode.Port)
|
||||
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference)
|
||||
if err != nil {
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||
return
|
||||
@@ -916,11 +709,14 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
|
||||
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata)
|
||||
}
|
||||
|
||||
func resolveChainProbeTarget(targetNode *nodeRecord, preferredPort int) (string, int, error) {
|
||||
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {
|
||||
if targetNode == nil {
|
||||
return "", 0, errors.New("目标节点不存在")
|
||||
}
|
||||
host := strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]")
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
|
||||
if err != nil {
|
||||
host = strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]")
|
||||
}
|
||||
if host == "" {
|
||||
return "", 0, errors.New("目标节点地址为空")
|
||||
}
|
||||
@@ -960,40 +756,7 @@ func firstPortFromRange(portRange string) int {
|
||||
}
|
||||
|
||||
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT CAST(ct.chain_type AS INTEGER), COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
|
||||
FROM chain_tunnel ct
|
||||
LEFT JOIN node n ON n.id = ct.node_id
|
||||
WHERE ct.tunnel_id = ?
|
||||
ORDER BY CAST(ct.chain_type AS INTEGER) ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
|
||||
`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]chainNodeRecord, 0)
|
||||
for rows.Next() {
|
||||
var item chainNodeRecord
|
||||
var name sql.NullString
|
||||
var protocol sql.NullString
|
||||
var strategy sql.NullString
|
||||
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name, &protocol, &strategy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(name.String) == "" {
|
||||
item.NodeName = fmt.Sprintf("node_%d", item.NodeID)
|
||||
} else {
|
||||
item.NodeName = name.String
|
||||
}
|
||||
item.Protocol = defaultString(protocol.String, "tls")
|
||||
item.Strategy = defaultString(strategy.String, "round")
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
return h.repo.ListChainNodesForTunnel(tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
||||
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
|
||||
t.Helper()
|
||||
var v int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryInt64Int64String(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, string) {
|
||||
t.Helper()
|
||||
var a int64
|
||||
var b int64
|
||||
var c string
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
|
||||
t.Fatalf("query int64+int64+string failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b, c
|
||||
}
|
||||
|
||||
func mustQueryInt64Int64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, int) {
|
||||
t.Helper()
|
||||
var a int64
|
||||
var b int64
|
||||
var c int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
|
||||
t.Fatalf("query int64+int64+int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b, c
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeSupportsV4 / nodeSupportsV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNodeSupportsV4_Nil(t *testing.T) {
|
||||
if nodeSupportsV4(nil) {
|
||||
t.Fatal("nil node must not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_Nil(t *testing.T) {
|
||||
if nodeSupportsV6(nil) {
|
||||
t.Fatal("nil node must not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("explicit server_ip_v4 must support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("explicit server_ip_v6 must support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_OnlyV6Set(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("node with only v6 should not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_OnlyV4Set(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("node with only v4 should not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_DualStack(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("dual-stack node must support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_DualStack(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("dual-stack node must support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "192.168.1.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip must support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip must not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip must support v6")
|
||||
}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip must not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_EmptyNode(t *testing.T) {
|
||||
n := &nodeRecord{}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("empty node must not support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("empty node must not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_LegacyBracketed(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "[::1]"}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("bracketed ipv6 must not support v4")
|
||||
}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("bracketed ipv6 must support v6")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pickNodeAddressV4 / pickNodeAddressV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPickNodeAddressV4_Nil(t *testing.T) {
|
||||
if pickNodeAddressV4(nil) != "" {
|
||||
t.Fatal("nil node must return empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV6_Nil(t *testing.T) {
|
||||
if pickNodeAddressV6(nil) != "" {
|
||||
t.Fatal("nil node must return empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV4_PreferExplicit(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIP: "192.168.0.1"}
|
||||
got := pickNodeAddressV4(n)
|
||||
if got != "10.0.0.1" {
|
||||
t.Fatalf("expected explicit v4 10.0.0.1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "192.168.0.1"}
|
||||
got := pickNodeAddressV4(n)
|
||||
if got != "192.168.0.1" {
|
||||
t.Fatalf("expected legacy 192.168.0.1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV6_PreferExplicit(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1", ServerIP: "::1"}
|
||||
got := pickNodeAddressV6(n)
|
||||
if got != "2001:db8::1" {
|
||||
t.Fatalf("expected explicit v6 2001:db8::1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "::1"}
|
||||
got := pickNodeAddressV6(n)
|
||||
if got != "::1" {
|
||||
t.Fatalf("expected legacy ::1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// selectTunnelDialHost — core IP preference selection logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func dualStackNode(name, v4, v6 string) *nodeRecord {
|
||||
return &nodeRecord{
|
||||
Name: name,
|
||||
ServerIPv4: v4,
|
||||
ServerIPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
func v4OnlyNode(name, v4 string) *nodeRecord {
|
||||
return &nodeRecord{
|
||||
Name: name,
|
||||
ServerIPv4: v4,
|
||||
}
|
||||
}
|
||||
|
||||
func v6OnlyNode(name, v6 string) *nodeRecord {
|
||||
return &nodeRecord{
|
||||
Name: name,
|
||||
ServerIPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
_, err := selectTunnelDialHost(nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil nodes")
|
||||
}
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil toNode")
|
||||
}
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil fromNode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Default prefers v4 when both available
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("default preference should pick v4, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("v4 preference should pick v4 address, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("v6 preference should pick v6 address, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// User prefers v6, but both nodes are v4-only — should fallback to v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("v6 preference on v4-only nodes should fallback to v4, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// User prefers v4, but both nodes are v6-only — should fallback to v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("v4 preference on v6-only nodes should fallback to v6, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Whitespace should be trimmed, treated as "v6"
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("trimmed v6 preference should pick v6 address, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// v6 preferred, but target only has v4 — should succeed with v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("should fallback to v4 when target is v4-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but target only has v6 — should succeed with v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("should fallback to v6 when target is v6-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("should use v4 when from is v4-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but from only has v6 — should use v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("should use v6 when from is v6-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeDisplayName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNodeDisplayName_Nil(t *testing.T) {
|
||||
got := nodeDisplayName(nil)
|
||||
if got != "node" {
|
||||
t.Fatalf("nil node display name should be 'node', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisplayName_Named(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42, Name: "hk-node"}
|
||||
got := nodeDisplayName(n)
|
||||
if got != "hk-node" {
|
||||
t.Fatalf("expected 'hk-node', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisplayName_Unnamed(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42}
|
||||
got := nodeDisplayName(n)
|
||||
if got != "node_42" {
|
||||
t.Fatalf("expected 'node_42', got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type federationTunnelRequest struct {
|
||||
@@ -107,7 +107,7 @@ type peerShareUsedPort struct {
|
||||
}
|
||||
|
||||
type peerShareListItem struct {
|
||||
sqlite.PeerShare
|
||||
repo.PeerShare
|
||||
UsedPorts []int `json:"usedPorts"`
|
||||
UsedPortDetails []peerShareUsedPort `json:"usedPortDetails"`
|
||||
ActiveRuntimeNum int `json:"activeRuntimeNum"`
|
||||
@@ -263,7 +263,7 @@ func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request)
|
||||
now := time.Now().UnixMilli()
|
||||
token := randomToken(32)
|
||||
|
||||
share := &sqlite.PeerShare{
|
||||
share := &repo.PeerShare{
|
||||
Name: req.Name,
|
||||
NodeID: req.NodeID,
|
||||
Token: token,
|
||||
@@ -298,13 +298,20 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
share, _ := h.repo.GetPeerShare(req.ID)
|
||||
|
||||
h.cleanupPeerShareRuntimes(req.ID)
|
||||
h.cleanupFederationTunnels(req.ID)
|
||||
|
||||
if err := h.repo.DeletePeerShare(req.ID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if share != nil && h.wsServer != nil {
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -422,40 +429,25 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, name, remote_url, remote_token, remote_config
|
||||
FROM node
|
||||
WHERE is_remote = 1
|
||||
ORDER BY id DESC
|
||||
`)
|
||||
remoteNodes, err := h.repo.ListRemoteNodes()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
localDomain := h.federationLocalDomain()
|
||||
|
||||
items := make([]remoteUsageNodeItem, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
nodeID int64
|
||||
nodeName string
|
||||
remoteURL sql.NullString
|
||||
remoteToken sql.NullString
|
||||
remoteConfig sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&nodeID, &nodeName, &remoteURL, &remoteToken, &remoteConfig); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, node := range remoteNodes {
|
||||
nodeID := node.ID
|
||||
nodeName := node.Name
|
||||
|
||||
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(remoteConfig.String)
|
||||
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig.String)
|
||||
|
||||
var syncError string
|
||||
url := strings.TrimSpace(remoteURL.String)
|
||||
token := strings.TrimSpace(remoteToken.String)
|
||||
url := strings.TrimSpace(node.RemoteURL.String)
|
||||
token := strings.TrimSpace(node.RemoteToken.String)
|
||||
if url != "" && token != "" {
|
||||
info, connectErr := fc.Connect(url, token, localDomain)
|
||||
if connectErr != nil {
|
||||
@@ -476,42 +468,34 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
"portRangeStart": info.PortRangeStart,
|
||||
"portRangeEnd": info.PortRangeEnd,
|
||||
})
|
||||
_, _ = h.repo.DB().Exec(`UPDATE node SET remote_config = ? WHERE id = ?`, string(configData), nodeID)
|
||||
_ = h.repo.UpdateNodeRemoteConfig(nodeID, string(configData))
|
||||
}
|
||||
}
|
||||
|
||||
bindingRows, err := h.repo.DB().Query(`
|
||||
SELECT fb.id, fb.tunnel_id, COALESCE(t.name, ''), fb.chain_type, fb.hop_inx, fb.allocated_port, fb.resource_key, fb.remote_binding_id, fb.updated_time
|
||||
FROM federation_tunnel_binding fb
|
||||
LEFT JOIN tunnel t ON t.id = fb.tunnel_id
|
||||
WHERE fb.node_id = ? AND fb.status = 1
|
||||
ORDER BY fb.allocated_port ASC, fb.id ASC
|
||||
`, nodeID)
|
||||
bindingRows, err := h.repo.ListActiveBindingsForNode(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
usedSet := make(map[int]struct{})
|
||||
bindings := make([]remoteUsageBindingItem, 0)
|
||||
for bindingRows.Next() {
|
||||
var item remoteUsageBindingItem
|
||||
if err := bindingRows.Scan(&item.BindingID, &item.TunnelID, &item.TunnelName, &item.ChainType, &item.HopInx, &item.AllocatedPort, &item.ResourceKey, &item.RemoteBindingID, &item.UpdatedTime); err != nil {
|
||||
_ = bindingRows.Close()
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
bindings = append(bindings, item)
|
||||
if item.AllocatedPort > 0 {
|
||||
usedSet[item.AllocatedPort] = struct{}{}
|
||||
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows))
|
||||
for _, b := range bindingRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: b.ID,
|
||||
TunnelID: b.TunnelID,
|
||||
TunnelName: b.TunnelName,
|
||||
ChainType: b.ChainType,
|
||||
HopInx: b.HopInx,
|
||||
AllocatedPort: b.AllocatedPort,
|
||||
ResourceKey: b.ResourceKey,
|
||||
RemoteBindingID: b.RemoteBindingID,
|
||||
UpdatedTime: b.UpdatedTime,
|
||||
})
|
||||
if b.AllocatedPort > 0 {
|
||||
usedSet[b.AllocatedPort] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := bindingRows.Err(); err != nil {
|
||||
_ = bindingRows.Close()
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
_ = bindingRows.Close()
|
||||
|
||||
usedPorts := make([]int, 0, len(usedSet))
|
||||
for port := range usedSet {
|
||||
@@ -535,14 +519,32 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
SyncError: syncError,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func remoteNodePortRange(node *nodeRecord) (int, int) {
|
||||
if node == nil || node.IsRemote != 1 || node.RemoteConfig == "" {
|
||||
return 0, 0
|
||||
}
|
||||
_, _, _, _, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig)
|
||||
return portRangeStart, portRangeEnd
|
||||
}
|
||||
|
||||
func validateRemoteNodePort(node *nodeRecord, port int) error {
|
||||
if node == nil || node.IsRemote != 1 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
start, end := remoteNodePortRange(node)
|
||||
if start <= 0 || end <= 0 {
|
||||
return nil
|
||||
}
|
||||
if port < start || port > end {
|
||||
return fmt.Errorf("远程节点端口 %d 超出允许范围 %d-%d", port, start, end)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int64, int, int) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -609,31 +611,21 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
portRange = fmt.Sprintf("%d-%d", info.PortRangeStart, info.PortRangeEnd)
|
||||
}
|
||||
|
||||
db := h.repo.DB()
|
||||
inx := nextIndex(db, "node")
|
||||
inx := h.repo.NextIndex("node")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
`,
|
||||
if err = h.repo.CreateRemoteNode(
|
||||
fmt.Sprintf("%s (Remote)", info.NodeName),
|
||||
randomToken(16), // Dummy secret
|
||||
randomToken(16),
|
||||
info.ServerIP,
|
||||
"", "", // v4/v6 unknown, use server_ip
|
||||
portRange,
|
||||
"",
|
||||
"",
|
||||
now, now,
|
||||
now,
|
||||
info.Status,
|
||||
"[::]", "[::]",
|
||||
inx,
|
||||
req.RemoteURL,
|
||||
req.Token,
|
||||
string(configBytes),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Database error: "+err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -725,11 +717,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var nodeName string
|
||||
var serverIP string
|
||||
var status int
|
||||
|
||||
err = h.repo.DB().QueryRow("SELECT name, server_ip, status FROM node WHERE id = ?", share.NodeID).Scan(&nodeName, &serverIP, &status)
|
||||
nodeInfo, err := h.repo.GetNodeBasicInfo(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Node not found"))
|
||||
return
|
||||
@@ -739,9 +727,9 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
"shareId": share.ID,
|
||||
"shareName": share.Name,
|
||||
"nodeId": share.NodeID,
|
||||
"nodeName": nodeName,
|
||||
"serverIp": serverIP,
|
||||
"status": status,
|
||||
"nodeName": nodeInfo.Name,
|
||||
"serverIp": nodeInfo.ServerIP,
|
||||
"status": nodeInfo.Status,
|
||||
"maxBandwidth": share.MaxBandwidth,
|
||||
"currentFlow": share.CurrentFlow,
|
||||
"expiryTime": share.ExpiryTime,
|
||||
@@ -778,48 +766,20 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelType := 1
|
||||
if strings.ToLower(req.Protocol) == "udp" {
|
||||
tunnelType = 2
|
||||
}
|
||||
|
||||
tx, err := h.repo.DB().Begin()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
|
||||
tunnelID, err := h.repo.CreateFederationTunnel(
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
tunnelType,
|
||||
1,
|
||||
req.Protocol,
|
||||
now,
|
||||
now,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, '1', ?, ?, 'fifo', 0, ?)`,
|
||||
tunnelID,
|
||||
share.NodeID,
|
||||
req.RemotePort,
|
||||
req.Protocol,
|
||||
)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
@@ -900,7 +860,7 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
runtime := &sqlite.PeerShareRuntime{
|
||||
runtime := &repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
@@ -954,7 +914,7 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
var runtime *sqlite.PeerShareRuntime
|
||||
var runtime *repo.PeerShareRuntime
|
||||
if strings.TrimSpace(req.ReservationID) != "" {
|
||||
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
|
||||
} else {
|
||||
@@ -982,6 +942,13 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
|
||||
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
|
||||
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -1118,7 +1085,7 @@ func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
var runtime *sqlite.PeerShareRuntime
|
||||
var runtime *repo.PeerShareRuntime
|
||||
if strings.TrimSpace(req.BindingID) != "" {
|
||||
runtime, err = h.repo.GetPeerShareRuntimeByBindingID(share.ID, strings.TrimSpace(req.BindingID))
|
||||
} else if strings.TrimSpace(req.ReservationID) != "" {
|
||||
@@ -1232,6 +1199,13 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if isFederationServiceCommand(cmd) {
|
||||
if err := validateFederationCommandPorts(share, req.Data); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -1249,7 +1223,56 @@ func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||
func isFederationServiceCommand(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) error {
|
||||
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
|
||||
return nil
|
||||
}
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if services, ok := dataMap["services"]; ok {
|
||||
serviceList, ok := services.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid services format")
|
||||
}
|
||||
for _, svc := range serviceList {
|
||||
svcMap, ok := svc.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid service entry format")
|
||||
}
|
||||
addr, ok := svcMap["addr"].(string)
|
||||
if !ok || addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service address: %s", addr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
return fmt.Errorf("invalid port in service address: %s", addr)
|
||||
}
|
||||
if port < share.PortRangeStart || port > share.PortRangeEnd {
|
||||
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) pickPeerSharePort(share *repo.PeerShare, requestedPort int) (int, error) {
|
||||
if share == nil {
|
||||
return 0, fmt.Errorf("share not found")
|
||||
}
|
||||
@@ -1259,29 +1282,13 @@ func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int)
|
||||
|
||||
used := make(map[int]struct{})
|
||||
|
||||
rows, err := h.repo.DB().Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL AND port > 0`, share.NodeID)
|
||||
nodePorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p sql.NullInt64
|
||||
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
|
||||
used[int(p.Int64)] = struct{}{}
|
||||
}
|
||||
for _, p := range nodePorts {
|
||||
used[p] = struct{}{}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
rows, err = h.repo.DB().Query(`SELECT port FROM forward_port WHERE node_id = ? AND port > 0`, share.NodeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p sql.NullInt64
|
||||
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
|
||||
used[int(p.Int64)] = struct{}{}
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
ports, err := h.repo.ListActivePeerShareRuntimePorts(share.ID, share.NodeID)
|
||||
if err != nil {
|
||||
@@ -1322,7 +1329,7 @@ func extractBearerToken(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPeerShareFlowExceeded(share *sqlite.PeerShare) bool {
|
||||
func isPeerShareFlowExceeded(share *repo.PeerShare) bool {
|
||||
if share == nil {
|
||||
return false
|
||||
}
|
||||
@@ -1559,3 +1566,18 @@ func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
||||
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupFederationTunnels(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
}
|
||||
namePrefix := fmt.Sprintf("Share-%d-Port-", shareID)
|
||||
tunnelIDs, err := h.repo.ListTunnelIDsByNamePrefix(namePrefix)
|
||||
if err != nil || len(tunnelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, tid := range tunnelIDs {
|
||||
_ = h.deleteTunnelByID(tid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,33 +10,33 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls"); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls").Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now); err != nil {
|
||||
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
share := &sqlite.PeerShare{
|
||||
share := &repo.PeerShare{
|
||||
ID: 77,
|
||||
NodeID: 1,
|
||||
PortRangeStart: 3000,
|
||||
@@ -56,14 +56,35 @@ func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
h := &Handler{}
|
||||
func TestApplyTunnelRuntimeSkipsRemoteChainAndOutNodes(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "rt-skip.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
for _, n := range []struct {
|
||||
id int64
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{12, "remote-chain", "10.99.0.2"},
|
||||
{13, "remote-out", "10.99.0.3"},
|
||||
} {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, n.id, n.name, n.name+"-secret", n.ip, n.ip, "", "40000-40010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://remote-peer", "remote-token").Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", n.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
state := &tunnelCreateState{
|
||||
TunnelID: 1,
|
||||
Type: 2,
|
||||
InNodes: []tunnelRuntimeNode{
|
||||
{NodeID: 11, ChainType: 1, Protocol: "tls"},
|
||||
},
|
||||
InNodes: []tunnelRuntimeNode{},
|
||||
ChainHops: [][]tunnelRuntimeNode{
|
||||
{
|
||||
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
|
||||
@@ -73,9 +94,8 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
|
||||
},
|
||||
Nodes: map[int64]*nodeRecord{
|
||||
11: {ID: 11, Name: "remote-in", IsRemote: 1},
|
||||
12: {ID: 12, Name: "remote-chain", IsRemote: 1},
|
||||
13: {ID: 13, Name: "remote-out", IsRemote: 1},
|
||||
12: {ID: 12, Name: "remote-chain", IsRemote: 1, ServerIPv4: "10.99.0.2"},
|
||||
13: {ID: 13, Name: "remote-out", IsRemote: 1, ServerIPv4: "10.99.0.3"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -84,47 +104,42 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
t.Fatalf("apply runtime: %v", err)
|
||||
}
|
||||
if len(chains) != 0 {
|
||||
t.Fatalf("expected no local chains created, got %d", len(chains))
|
||||
t.Fatalf("expected no local chains for remote-only nodes, got %d", len(chains))
|
||||
}
|
||||
if len(services) != 0 {
|
||||
t.Fatalf("expected no local services created, got %d", len(services))
|
||||
t.Fatalf("expected no local services for remote-only nodes, got %d", len(services))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
res, execErr := repo.DB().Exec(`
|
||||
if execErr := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`)
|
||||
if execErr != nil {
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`).Error; execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
id, idErr := res.LastInsertId()
|
||||
if idErr != nil {
|
||||
t.Fatalf("node id %s: %v", name, idErr)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("entry", 1, "31000-31010", 0)
|
||||
remoteOutID := insertNode("remote-out", 1, "30000", 1)
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
tx, err := repo.DB().Begin()
|
||||
if err != nil {
|
||||
tx := r.DB().Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
@@ -153,36 +168,31 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
||||
}
|
||||
|
||||
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
res, execErr := repo.DB().Exec(`
|
||||
if execErr := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`)
|
||||
if execErr != nil {
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`).Error; execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
id, idErr := res.LastInsertId()
|
||||
if idErr != nil {
|
||||
t.Fatalf("node id %s: %v", name, idErr)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("entry-local", 1, "32000-32010", 0)
|
||||
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
|
||||
outID := insertNode("out-local", 1, "34000-34010", 0)
|
||||
|
||||
tx, err := repo.DB().Begin()
|
||||
if err != nil {
|
||||
tx := r.DB().Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
@@ -218,16 +228,16 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "limited-share",
|
||||
NodeID: 1,
|
||||
Token: "limited-token",
|
||||
|
||||
@@ -12,30 +12,26 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`)
|
||||
if err != nil {
|
||||
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
remoteNodeID, err := insertRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get remote node id: %v", err)
|
||||
}
|
||||
remoteNodeID := mustLastInsertID(t, r, "remote-share-node")
|
||||
|
||||
body, err := json.Marshal(createPeerShareRequest{
|
||||
Name: "remote-node-share",
|
||||
@@ -70,36 +66,29 @@ func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
|
||||
t.Fatalf("expected rejection message %q, got %q", "Only local nodes can be shared", payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("query peer_share count: %v", err)
|
||||
}
|
||||
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID)
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected no share rows for remote node, got %d", shareCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "")
|
||||
if err != nil {
|
||||
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
localNodeID, err := insertRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get local node id: %v", err)
|
||||
}
|
||||
localNodeID := mustLastInsertID(t, r, "local-share-node")
|
||||
|
||||
body, err := json.Marshal(createPeerShareRequest{
|
||||
Name: "local-node-share",
|
||||
@@ -135,26 +124,23 @@ func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
||||
t.Fatalf("expected invalid IP message, got %q", payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("query peer_share count: %v", err)
|
||||
}
|
||||
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID)
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected no share rows for node, got %d", shareCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "provider-share",
|
||||
NodeID: 9,
|
||||
Token: "share-list-token",
|
||||
@@ -169,12 +155,12 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("share-list-token")
|
||||
share, err := r.GetPeerShareByToken("share-list-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
@@ -183,7 +169,7 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
|
||||
); err != nil {
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
@@ -238,16 +224,16 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-cleanup-share",
|
||||
NodeID: 99,
|
||||
Token: "delete-cleanup-token",
|
||||
@@ -261,26 +247,23 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("delete-cleanup-token")
|
||||
share, err := r.GetPeerShareByToken("delete-cleanup-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
|
||||
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
|
||||
); err != nil {
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
var runtimeCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID).Scan(&runtimeCount); err != nil {
|
||||
t.Fatalf("count active runtimes before: %v", err)
|
||||
}
|
||||
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID)
|
||||
if runtimeCount != 2 {
|
||||
t.Fatalf("expected 2 active runtimes before delete, got %d", runtimeCount)
|
||||
}
|
||||
@@ -306,37 +289,31 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("count peer_share after: %v", err)
|
||||
}
|
||||
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID)
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected peer_share deleted, got %d rows", shareCount)
|
||||
}
|
||||
|
||||
var runtimeCountAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID).Scan(&runtimeCountAfter); err != nil {
|
||||
t.Fatalf("count peer_share_runtime after: %v", err)
|
||||
}
|
||||
runtimeCountAfter := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID)
|
||||
if runtimeCountAfter != 0 {
|
||||
t.Fatalf("expected all peer_share_runtime rows deleted, got %d", runtimeCountAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`); err != nil {
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
@@ -380,15 +357,15 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationShareResetFlow(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "reset-flow-share",
|
||||
NodeID: 11,
|
||||
Token: "reset-flow-token",
|
||||
@@ -402,7 +379,7 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("reset-flow-token")
|
||||
share, err := r.GetPeerShareByToken("reset-flow-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
@@ -428,7 +405,7 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
updated, err := repo.GetPeerShare(share.ID)
|
||||
updated, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updated == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
@@ -438,47 +415,41 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
resNode, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
|
||||
if err != nil {
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
nodeID, err := resNode.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("remote node id: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
|
||||
|
||||
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel a: %v", err)
|
||||
}
|
||||
tunnelAID, _ := resTunnelA.LastInsertId()
|
||||
tunnelAID := mustLastInsertID(t, r, "consumer-tunnel-a")
|
||||
|
||||
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel b: %v", err)
|
||||
}
|
||||
tunnelBID, _ := resTunnelB.LastInsertId()
|
||||
tunnelBID := mustLastInsertID(t, r, "consumer-tunnel-b")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
||||
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
||||
); err != nil {
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert federation bindings: %v", err)
|
||||
}
|
||||
|
||||
@@ -532,13 +503,13 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
tests := []struct {
|
||||
@@ -585,7 +556,7 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
for idx, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := fmt.Sprintf("share-token-%d", idx)
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "share-" + tt.name,
|
||||
NodeID: 1,
|
||||
Token: token,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -207,22 +206,18 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
|
||||
if userTunnelID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, user_id, tunnel_id, flow, in_flow, out_flow, exp_time, status
|
||||
FROM user_tunnel
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`, userTunnelID)
|
||||
|
||||
var policy userTunnelPolicy
|
||||
if err := row.Scan(&policy.ID, &policy.UserID, &policy.TunnelID, &policy.Flow, &policy.InFlow, &policy.OutFlow, &policy.ExpTime, &policy.Status); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
ut, err := h.repo.GetUserTunnelByID(userTunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
if ut == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &userTunnelPolicy{
|
||||
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
|
||||
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) pauseUserForwards(userID int64, now int64) {
|
||||
@@ -245,60 +240,20 @@ func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
|
||||
for i := range forwards {
|
||||
forward := forwards[i]
|
||||
_ = h.controlForwardServices(&forward, "PauseService", false)
|
||||
_, _ = h.repo.DB().Exec(`UPDATE forward SET status = 0, updated_time = ? WHERE id = ?`, now, forward.ID)
|
||||
_ = h.repo.UpdateForwardStatus(forward.ID, 0, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward
|
||||
WHERE user_id = ? AND status = 1
|
||||
ORDER BY id ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanForwardRecords(rows)
|
||||
return h.repo.ListActiveForwardsByUser(userID)
|
||||
}
|
||||
|
||||
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward
|
||||
WHERE user_id = ? AND tunnel_id = ? AND status = 1
|
||||
ORDER BY id ASC
|
||||
`, userID, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanForwardRecords(rows)
|
||||
}
|
||||
|
||||
func scanForwardRecords(rows *sql.Rows) ([]forwardRecord, error) {
|
||||
out := make([]forwardRecord, 0)
|
||||
for rows.Next() {
|
||||
var record forwardRecord
|
||||
if err := rows.Scan(&record.ID, &record.UserID, &record.UserName, &record.Name, &record.TunnelID, &record.RemoteAddr, &record.Strategy, &record.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(record.Strategy) == "" {
|
||||
record.Strategy = "fifo"
|
||||
}
|
||||
out = append(out, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
return h.repo.ListActiveForwardsByUserTunnel(userID, tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil || nodeID <= 0 {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(rawConfig) == "" {
|
||||
@@ -383,15 +338,13 @@ func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelExists(tunnelID int64) bool {
|
||||
var count int
|
||||
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE id = ?`, tunnelID).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
ok, _ := h.repo.TunnelExists(tunnelID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) forwardExists(forwardID int64) bool {
|
||||
var count int
|
||||
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
ok, _ := h.repo.ForwardExists(forwardID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
@@ -402,8 +355,6 @@ func (h *Handler) speedLimiterExists(name string) bool {
|
||||
if err != nil || id <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var count int
|
||||
err = h.repo.DB().QueryRow(`SELECT COUNT(1) FROM speed_limit WHERE id = ?`, id).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
ok, _ := h.repo.SpeedLimitExists(id)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-share",
|
||||
NodeID: 1,
|
||||
Token: "flow-share-token",
|
||||
@@ -30,22 +30,22 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("flow-share-token")
|
||||
share, err := r.GetPeerShareByToken("flow-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
|
||||
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := repo.GetPeerShare(share.ID)
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
|
||||
runtime, err := repo.GetPeerShareRuntimeByID(17)
|
||||
runtime, err := r.GetPeerShareRuntimeByID(17)
|
||||
if err != nil || runtime == nil {
|
||||
t.Fatalf("reload runtime: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ import (
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo *sqlite.Repository
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
|
||||
@@ -69,7 +69,7 @@ type flowItem struct {
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
@@ -177,6 +177,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
|
||||
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
@@ -424,7 +426,7 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "database unavailable"))
|
||||
return
|
||||
}
|
||||
@@ -467,27 +469,21 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var userID int64
|
||||
var inFlow int64
|
||||
var outFlow int64
|
||||
var flow int64
|
||||
var expTime int64
|
||||
err = h.repo.DB().QueryRow(`SELECT user_id, in_flow, out_flow, flow, exp_time FROM user_tunnel WHERE id = ? LIMIT 1`, tunnelID).
|
||||
Scan(&userID, &inFlow, &outFlow, &flow, &expTime)
|
||||
ut, err := h.repo.GetUserTunnelByID(tunnelID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if userID != user.ID {
|
||||
if ut == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
if ut.UserID != user.ID {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
headerValue = buildSubscriptionHeader(outFlow, inFlow, flow*giga, expTime/1000)
|
||||
headerValue = buildSubscriptionHeader(ut.OutFlow, ut.InFlow, ut.Flow*giga, ut.ExpTime/1000)
|
||||
}
|
||||
|
||||
w.Header().Set("subscription-userinfo", headerValue)
|
||||
@@ -1188,7 +1184,7 @@ func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type backupImportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
sqlite.BackupData
|
||||
repo.BackupData
|
||||
}
|
||||
|
||||
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1228,3 +1224,53 @@ func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||
result.AutoBackup = autoBackup
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
ann, err := h.repo.GetAnnouncement()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("获取公告失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
if ann == nil {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) updateAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Enabled int `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertAnnouncement(req.Content, req.Enabled, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("更新公告失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *Handler) StartBackgroundJobs() {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,47 +96,28 @@ func durationUntilNextDailyMaintenance(now time.Time) time.Duration {
|
||||
}
|
||||
|
||||
func (h *Handler) runStatisticsFlowJob(now time.Time) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
db := h.repo.DB()
|
||||
nowMs := now.UnixMilli()
|
||||
cutoffMs := nowMs - int64((48*time.Hour)/time.Millisecond)
|
||||
_, _ = db.Exec(`DELETE FROM statistics_flow WHERE created_time < ?`, cutoffMs)
|
||||
_ = h.repo.PurgeOldStatisticsFlows(cutoffMs)
|
||||
|
||||
hourMark := now.Truncate(time.Hour)
|
||||
hourText := hourMark.Format("15:04")
|
||||
createdTime := hourMark.UnixMilli()
|
||||
|
||||
rows, err := db.Query(`SELECT id, in_flow, out_flow FROM user ORDER BY id ASC`)
|
||||
users, err := h.repo.ListAllUserFlowSnapshots()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type userFlowSnapshot struct {
|
||||
userID int64
|
||||
inFlow int64
|
||||
outFlow int64
|
||||
}
|
||||
users := make([]userFlowSnapshot, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var inFlow int64
|
||||
var outFlow int64
|
||||
if err := rows.Scan(&userID, &inFlow, &outFlow); err != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, userFlowSnapshot{userID: userID, inFlow: inFlow, outFlow: outFlow})
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, user := range users {
|
||||
currentTotal := user.inFlow + user.outFlow
|
||||
currentTotal := user.InFlow + user.OutFlow
|
||||
increment := currentTotal
|
||||
|
||||
var lastTotal sql.NullInt64
|
||||
err := db.QueryRow(`SELECT total_flow FROM statistics_flow WHERE user_id = ? ORDER BY id DESC LIMIT 1`, user.userID).Scan(&lastTotal)
|
||||
lastTotal, err := h.repo.GetLastStatisticsFlowTotal(user.UserID)
|
||||
if err == nil && lastTotal.Valid {
|
||||
increment = currentTotal - lastTotal.Int64
|
||||
if increment < 0 {
|
||||
@@ -145,15 +125,12 @@ func (h *Handler) runStatisticsFlowJob(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = db.Exec(`
|
||||
INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
`, user.userID, increment, currentTotal, hourText, createdTime)
|
||||
_ = h.repo.CreateStatisticsFlow(user.UserID, increment, currentTotal, hourText, createdTime)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,108 +140,39 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
||||
}
|
||||
|
||||
func (h *Handler) resetMonthlyFlow(now time.Time) {
|
||||
db := h.repo.DB()
|
||||
currentDay := now.Day()
|
||||
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
|
||||
if currentDay == lastDay {
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND (flow_reset_time = ? OR flow_reset_time > ?)
|
||||
`, currentDay, lastDay)
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user_tunnel
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND (flow_reset_time = ? OR flow_reset_time > ?)
|
||||
`, currentDay, lastDay)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND flow_reset_time = ?
|
||||
`, currentDay)
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user_tunnel
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND flow_reset_time = ?
|
||||
`, currentDay)
|
||||
_ = h.repo.ResetUserMonthlyFlow(currentDay, lastDay)
|
||||
_ = h.repo.ResetUserTunnelMonthlyFlow(currentDay, lastDay)
|
||||
}
|
||||
|
||||
func (h *Handler) disableExpiredUsers(nowMs int64) {
|
||||
db := h.repo.DB()
|
||||
rows, err := db.Query(`
|
||||
SELECT id
|
||||
FROM user
|
||||
WHERE role_id != 0
|
||||
AND status = 1
|
||||
AND exp_time IS NOT NULL
|
||||
AND exp_time < ?
|
||||
`, nowMs)
|
||||
userIDs, err := h.repo.ListExpiredActiveUserIDs(nowMs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
userIDs := make([]int64, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, userID := range userIDs {
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err == nil {
|
||||
h.pauseForwardRecords(forwards, nowMs)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE user SET status = 0 WHERE id = ?`, userID)
|
||||
_ = h.repo.DisableUser(userID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
|
||||
db := h.repo.DB()
|
||||
rows, err := db.Query(`
|
||||
SELECT id, user_id, tunnel_id
|
||||
FROM user_tunnel
|
||||
WHERE status = 1
|
||||
AND exp_time IS NOT NULL
|
||||
AND exp_time < ?
|
||||
`, nowMs)
|
||||
items, err := h.repo.ListExpiredActiveUserTunnels(nowMs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type expiredUserTunnel struct {
|
||||
userTunnelID int64
|
||||
userID int64
|
||||
tunnelID int64
|
||||
}
|
||||
items := make([]expiredUserTunnel, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var userTunnelID int64
|
||||
var userID int64
|
||||
var tunnelID int64
|
||||
if err := rows.Scan(&userTunnelID, &userID, &tunnelID); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, expiredUserTunnel{userTunnelID: userTunnelID, userID: userID, tunnelID: tunnelID})
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, item := range items {
|
||||
forwards, err := h.listActiveForwardsByUserTunnel(item.userID, item.tunnelID)
|
||||
forwards, err := h.listActiveForwardsByUserTunnel(item.UserID, item.TunnelID)
|
||||
if err == nil {
|
||||
h.pauseForwardRecords(forwards, nowMs)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE user_tunnel SET status = 0 WHERE id = ?`, item.userTunnelID)
|
||||
_ = h.repo.DisableUserTunnel(item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,48 +5,40 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-stats.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "secret")
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`); err != nil {
|
||||
if err := r.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`).Error; err != nil {
|
||||
t.Fatalf("seed user flow: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed recent statistics row: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed stale statistics row: %v", err)
|
||||
}
|
||||
|
||||
h.runStatisticsFlowJob(now)
|
||||
|
||||
var staleCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond)).Scan(&staleCount); err != nil {
|
||||
t.Fatalf("query stale statistics rows: %v", err)
|
||||
}
|
||||
staleCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond))
|
||||
if staleCount != 0 {
|
||||
t.Fatalf("expected stale statistics rows to be pruned, got %d", staleCount)
|
||||
}
|
||||
|
||||
var flow int64
|
||||
var total int64
|
||||
var hour string
|
||||
if err := repo.DB().QueryRow(`SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`).Scan(&flow, &total, &hour); err != nil {
|
||||
t.Fatalf("query latest statistics row: %v", err)
|
||||
}
|
||||
flow, total, hour := mustQueryInt64Int64String(t, r, `SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`)
|
||||
if flow != 50 {
|
||||
t.Fatalf("expected increment flow 50, got %d", flow)
|
||||
}
|
||||
@@ -60,68 +52,57 @@ func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
|
||||
|
||||
func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-reset.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "secret")
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 5, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'expired_user', 'x', 1, ?, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||
`, nowMs-1000, nowMs, nowMs); err != nil {
|
||||
`, nowMs-1000, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert expired user: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, nowMs, nowMs); err != nil {
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 1, 1, 300, 400, 15, ?, 1)
|
||||
`, nowMs-1000); err != nil {
|
||||
`, nowMs-1000).Error; err != nil {
|
||||
t.Fatalf("insert expired user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, nowMs, nowMs); err != nil {
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
var userIn, userOut int64
|
||||
var userStatus int
|
||||
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user WHERE id = 2`).Scan(&userIn, &userOut, &userStatus); err != nil {
|
||||
t.Fatalf("query user after maintenance: %v", err)
|
||||
}
|
||||
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
|
||||
if userIn != 0 || userOut != 0 || userStatus != 0 {
|
||||
t.Fatalf("expected user reset+disabled, got in=%d out=%d status=%d", userIn, userOut, userStatus)
|
||||
}
|
||||
|
||||
var utIn, utOut int64
|
||||
var utStatus int
|
||||
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`).Scan(&utIn, &utOut, &utStatus); err != nil {
|
||||
t.Fatalf("query user_tunnel after maintenance: %v", err)
|
||||
}
|
||||
utIn, utOut, utStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`)
|
||||
if utIn != 0 || utOut != 0 || utStatus != 0 {
|
||||
t.Fatalf("expected user_tunnel reset+disabled, got in=%d out=%d status=%d", utIn, utOut, utStatus)
|
||||
}
|
||||
|
||||
var forwardStatus int
|
||||
if err := repo.DB().QueryRow(`SELECT status FROM forward WHERE id = 20`).Scan(&forwardStatus); err != nil {
|
||||
t.Fatalf("query forward after maintenance: %v", err)
|
||||
}
|
||||
forwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`)
|
||||
if forwardStatus != 0 {
|
||||
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,8 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
case "/api/v1/announcement/update":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
// Package store provides a thin dialect-aware wrapper around database/sql,
|
||||
// enabling transparent use of both SQLite and PostgreSQL.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Dialect identifies the underlying database engine.
|
||||
type Dialect int
|
||||
|
||||
const (
|
||||
DialectSQLite Dialect = iota
|
||||
DialectPostgres
|
||||
)
|
||||
|
||||
// String returns a human-readable dialect name.
|
||||
func (d Dialect) String() string {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return "sqlite"
|
||||
case DialectPostgres:
|
||||
return "postgres"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DB wraps *sql.DB with dialect awareness.
|
||||
type DB struct {
|
||||
raw *sql.DB
|
||||
dialect Dialect
|
||||
}
|
||||
|
||||
// Wrap creates a new dialect-aware DB from an existing *sql.DB.
|
||||
func Wrap(raw *sql.DB, dialect Dialect) *DB {
|
||||
return &DB{raw: raw, dialect: dialect}
|
||||
}
|
||||
|
||||
// Dialect returns the database dialect.
|
||||
func (db *DB) Dialect() Dialect {
|
||||
if db == nil {
|
||||
return DialectSQLite
|
||||
}
|
||||
return db.dialect
|
||||
}
|
||||
|
||||
// RawDB returns the underlying *sql.DB.
|
||||
func (db *DB) RawDB() *sql.DB {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return db.raw
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (db *DB) Close() error {
|
||||
if db == nil || db.raw == nil {
|
||||
return nil
|
||||
}
|
||||
return db.raw.Close()
|
||||
}
|
||||
|
||||
// Ping verifies the connection is alive.
|
||||
func (db *DB) Ping() error {
|
||||
return db.raw.Ping()
|
||||
}
|
||||
|
||||
// Exec executes a query with transparent placeholder and syntax rewriting.
|
||||
func (db *DB) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return db.raw.Exec(db.rewrite(query), args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows, with transparent rewriting.
|
||||
func (db *DB) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return db.raw.Query(db.rewrite(query), args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns at most one row, with transparent rewriting.
|
||||
func (db *DB) QueryRow(query string, args ...any) *sql.Row {
|
||||
return db.raw.QueryRow(db.rewrite(query), args...)
|
||||
}
|
||||
|
||||
// Begin starts a transaction, returning a dialect-aware Tx.
|
||||
func (db *DB) Begin() (*Tx, error) {
|
||||
tx, err := db.raw.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Tx{raw: tx, dialect: db.dialect}, nil
|
||||
}
|
||||
|
||||
// ExecReturningID executes an INSERT and returns the auto-generated id.
|
||||
// - SQLite: uses LastInsertId()
|
||||
// - PostgreSQL: appends RETURNING id and uses QueryRow().Scan()
|
||||
func (db *DB) ExecReturningID(query string, args ...any) (int64, error) {
|
||||
q := db.rewrite(query)
|
||||
if db.dialect == DialectPostgres {
|
||||
q = ensureReturningID(q)
|
||||
var id int64
|
||||
if err := db.raw.QueryRow(q, args...).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
res, err := db.raw.Exec(q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// Tx wraps *sql.Tx with dialect awareness.
|
||||
type Tx struct {
|
||||
raw *sql.Tx
|
||||
dialect Dialect
|
||||
}
|
||||
|
||||
// Exec executes a query inside the transaction with transparent rewriting.
|
||||
func (tx *Tx) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return tx.raw.Exec(rewriteQuery(tx.dialect, query), args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows inside the transaction.
|
||||
func (tx *Tx) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return tx.raw.Query(rewriteQuery(tx.dialect, query), args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns at most one row inside the transaction.
|
||||
func (tx *Tx) QueryRow(query string, args ...any) *sql.Row {
|
||||
return tx.raw.QueryRow(rewriteQuery(tx.dialect, query), args...)
|
||||
}
|
||||
|
||||
// Commit commits the transaction.
|
||||
func (tx *Tx) Commit() error { return tx.raw.Commit() }
|
||||
|
||||
// Rollback aborts the transaction.
|
||||
func (tx *Tx) Rollback() error { return tx.raw.Rollback() }
|
||||
|
||||
// ExecReturningID executes an INSERT inside the transaction and returns the id.
|
||||
func (tx *Tx) ExecReturningID(query string, args ...any) (int64, error) {
|
||||
q := rewriteQuery(tx.dialect, query)
|
||||
if tx.dialect == DialectPostgres {
|
||||
q = ensureReturningID(q)
|
||||
var id int64
|
||||
if err := tx.raw.QueryRow(q, args...).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
res, err := tx.raw.Exec(q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (db *DB) rewrite(query string) string {
|
||||
return rewriteQuery(db.dialect, query)
|
||||
}
|
||||
|
||||
func rewriteQuery(dialect Dialect, query string) string {
|
||||
if dialect != DialectPostgres {
|
||||
return query
|
||||
}
|
||||
query = rewriteUserIdentifier(query)
|
||||
query = rewriteInsertOrIgnore(query)
|
||||
query = rewritePlaceholders(query)
|
||||
return query
|
||||
}
|
||||
|
||||
func rewriteUserIdentifier(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
i := 0
|
||||
for i < len(query) {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
buf.WriteString(query[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if isIdentifierChar(ch) {
|
||||
j := i + 1
|
||||
for j < len(query) && isIdentifierChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
tok := query[i:j]
|
||||
if strings.EqualFold(tok, "user") {
|
||||
buf.WriteString(`"user"`)
|
||||
} else {
|
||||
buf.WriteString(tok)
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
buf.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func isIdentifierChar(ch byte) bool {
|
||||
if ch >= 'a' && ch <= 'z' {
|
||||
return true
|
||||
}
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
return true
|
||||
}
|
||||
if ch >= '0' && ch <= '9' {
|
||||
return true
|
||||
}
|
||||
return ch == '_'
|
||||
}
|
||||
|
||||
func rewriteInsertOrIgnore(query string) string {
|
||||
start, end, ok := findKeywordSequenceOutside(query, []string{"INSERT", "OR", "IGNORE", "INTO"}, 0)
|
||||
if !ok {
|
||||
return query
|
||||
}
|
||||
|
||||
rewritten := query[:start] + "INSERT INTO" + query[end:]
|
||||
rewritten = strings.TrimRight(rewritten, "; \t\n")
|
||||
|
||||
insertIntoEnd := start + len("INSERT INTO")
|
||||
if _, _, hasOnConflict := findKeywordSequenceOutside(rewritten, []string{"ON", "CONFLICT"}, insertIntoEnd); hasOnConflict {
|
||||
return rewritten
|
||||
}
|
||||
|
||||
if retStart, _, hasReturning := findKeywordSequenceOutside(rewritten, []string{"RETURNING"}, insertIntoEnd); hasReturning {
|
||||
prefix := strings.TrimRight(rewritten[:retStart], " \t\n")
|
||||
suffix := strings.TrimLeft(rewritten[retStart:], " \t\n")
|
||||
return prefix + " ON CONFLICT DO NOTHING " + suffix
|
||||
}
|
||||
|
||||
return rewritten + " ON CONFLICT DO NOTHING"
|
||||
}
|
||||
|
||||
func rewritePlaceholders(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
n := 1
|
||||
for i := 0; i < len(query); i++ {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
buf.WriteString(query[i:end])
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if ch == '?' {
|
||||
buf.WriteByte('$')
|
||||
buf.WriteString(strconv.Itoa(n))
|
||||
n++
|
||||
continue
|
||||
}
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func ensureReturningID(query string) string {
|
||||
trimmed := strings.TrimRight(query, "; \t\n")
|
||||
if _, _, ok := findKeywordSequenceOutside(trimmed, []string{"RETURNING"}, 0); ok {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed + " RETURNING id"
|
||||
}
|
||||
|
||||
func findKeywordSequenceOutside(query string, keywords []string, from int) (int, int, bool) {
|
||||
if len(keywords) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if from >= len(query) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
matched := 0
|
||||
seqStart := -1
|
||||
|
||||
for i := from; i < len(query); {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if isIdentifierChar(ch) {
|
||||
j := i + 1
|
||||
for j < len(query) && isIdentifierChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
tok := query[i:j]
|
||||
|
||||
if strings.EqualFold(tok, keywords[matched]) {
|
||||
if matched == 0 {
|
||||
seqStart = i
|
||||
}
|
||||
matched++
|
||||
if matched == len(keywords) {
|
||||
return seqStart, j, true
|
||||
}
|
||||
} else if strings.EqualFold(tok, keywords[0]) {
|
||||
seqStart = i
|
||||
matched = 1
|
||||
} else {
|
||||
matched = 0
|
||||
seqStart = -1
|
||||
}
|
||||
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if !isSQLSpace(ch) {
|
||||
matched = 0
|
||||
seqStart = -1
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func skipSQLProtectedSegment(query string, i int) (int, bool) {
|
||||
if i < 0 || i >= len(query) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch query[i] {
|
||||
case '\'':
|
||||
return skipSingleQuotedLiteral(query, i), true
|
||||
case '"':
|
||||
return skipDoubleQuotedIdentifier(query, i), true
|
||||
case '-':
|
||||
if i+1 < len(query) && query[i+1] == '-' {
|
||||
return skipLineComment(query, i), true
|
||||
}
|
||||
case '/':
|
||||
if i+1 < len(query) && query[i+1] == '*' {
|
||||
return skipBlockComment(query, i), true
|
||||
}
|
||||
case '$':
|
||||
if end, ok := skipDollarQuotedLiteral(query, i); ok {
|
||||
return end, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func skipSingleQuotedLiteral(query string, i int) int {
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] != '\'' {
|
||||
continue
|
||||
}
|
||||
if j+1 < len(query) && query[j+1] == '\'' {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
return j + 1
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipDoubleQuotedIdentifier(query string, i int) int {
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] != '"' {
|
||||
continue
|
||||
}
|
||||
if j+1 < len(query) && query[j+1] == '"' {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
return j + 1
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipLineComment(query string, i int) int {
|
||||
for j := i + 2; j < len(query); j++ {
|
||||
if query[j] == '\n' {
|
||||
return j
|
||||
}
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipBlockComment(query string, i int) int {
|
||||
depth := 1
|
||||
for j := i + 2; j < len(query)-1; j++ {
|
||||
if query[j] == '/' && query[j+1] == '*' {
|
||||
depth++
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if query[j] == '*' && query[j+1] == '/' {
|
||||
depth--
|
||||
j++
|
||||
if depth == 0 {
|
||||
return j + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipDollarQuotedLiteral(query string, i int) (int, bool) {
|
||||
if i < 0 || i >= len(query) || query[i] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if i+1 >= len(query) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var endTag int
|
||||
if query[i+1] == '$' {
|
||||
endTag = i + 1
|
||||
} else {
|
||||
if !isDollarTagStart(query[i+1]) {
|
||||
return 0, false
|
||||
}
|
||||
j := i + 2
|
||||
for j < len(query) && isDollarTagChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
if j >= len(query) || query[j] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
endTag = j
|
||||
}
|
||||
|
||||
tag := query[i : endTag+1]
|
||||
if closeIdx := strings.Index(query[endTag+1:], tag); closeIdx >= 0 {
|
||||
return endTag + 1 + closeIdx + len(tag), true
|
||||
}
|
||||
return len(query), true
|
||||
}
|
||||
|
||||
func isDollarTagStart(ch byte) bool {
|
||||
return ch == '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
|
||||
}
|
||||
|
||||
func isDollarTagChar(ch byte) bool {
|
||||
if isDollarTagStart(ch) {
|
||||
return true
|
||||
}
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
func isSQLSpace(ch byte) bool {
|
||||
switch ch {
|
||||
case ' ', '\t', '\n', '\r', '\f':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRewritePlaceholdersSkipsProtectedSegments(t *testing.T) {
|
||||
q := `SELECT ?, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
|
||||
FROM t /* block ? */ WHERE id = ?`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
|
||||
FROM t /* block ? */ WHERE id = $2`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreBasic(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreBeforeReturning(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO x(a) VALUES(?) RETURNING id`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT DO NOTHING RETURNING id`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreNotDuplicatingOnConflict(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReturningID(t *testing.T) {
|
||||
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1)`); got != `INSERT INTO x(a) VALUES($1) RETURNING id` {
|
||||
t.Fatalf("missing RETURNING append: %s", got)
|
||||
}
|
||||
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1) RETURNING other_id`); got != `INSERT INTO x(a) VALUES($1) RETURNING other_id` {
|
||||
t.Fatalf("RETURNING should not be duplicated: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUserIdentifierSafety(t *testing.T) {
|
||||
q := `SELECT user, user_id, 'user', "user", note FROM user -- user
|
||||
WHERE owner='user'`
|
||||
got := rewriteUserIdentifier(q)
|
||||
want := `SELECT "user", user_id, 'user', "user", note FROM "user" -- user
|
||||
WHERE owner='user'`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteQueryPostgresPipeline(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO user(name, note) VALUES(?, '?')`
|
||||
got := rewriteQuery(DialectPostgres, q)
|
||||
want := `INSERT INTO "user"(name, note) VALUES($1, '?') ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreSkipsStringLiteral(t *testing.T) {
|
||||
q := `SELECT 'INSERT OR IGNORE INTO t(a) VALUES(?)' AS q`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
if got != q {
|
||||
t.Fatalf("string literal should stay unchanged\nwant: %s\ngot: %s", q, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreSkipsCommentedKeyword(t *testing.T) {
|
||||
q := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
|
||||
INSERT OR IGNORE INTO real_t(a) VALUES(?)`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
|
||||
INSERT INTO real_t(a) VALUES(?) ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritePlaceholdersSkipsNestedBlockComment(t *testing.T) {
|
||||
q := `SELECT ? /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = ?`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1 /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = $2`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritePlaceholdersSkipsUnterminatedBlockComment(t *testing.T) {
|
||||
q := `SELECT ? /* unterminated ? comment`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1 /* unterminated ? comment`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUserIdentifierSkipsDollarQuotedAndComment(t *testing.T) {
|
||||
q := `SELECT user, $$user ?$$ AS body, col FROM user /* user */ -- user`
|
||||
got := rewriteUserIdentifier(q)
|
||||
want := `SELECT "user", $$user ?$$ AS body, col FROM "user" /* user */ -- user`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
// Package model defines GORM model structs for all database tables,
|
||||
// providing a single source of truth for the schema that works
|
||||
// transparently with both SQLite and PostgreSQL.
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// ─── Core Business Tables ────────────────────────────────────────────
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "user" }
|
||||
|
||||
// Forward maps to the "forward" table.
|
||||
type Forward struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
func (Forward) TableName() string { return "forward" }
|
||||
|
||||
type ForwardPort struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ForwardPort) TableName() string { return "forward_port" }
|
||||
|
||||
type Node struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Secret string `gorm:"type:varchar(100);not null"`
|
||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||
ServerIPV6 sql.NullString `gorm:"column:server_ip_v6;type:varchar(100)"`
|
||||
Port string `gorm:"type:text;not null"`
|
||||
InterfaceName sql.NullString `gorm:"column:interface_name;type:varchar(200)"`
|
||||
Version sql.NullString `gorm:"type:varchar(100)"`
|
||||
HTTP int `gorm:"column:http;not null;default:0"`
|
||||
TLS int `gorm:"column:tls;not null;default:0"`
|
||||
Socks int `gorm:"not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
TCPListenAddr string `gorm:"column:tcp_listen_addr;type:varchar(100);not null;default:'[::]'"`
|
||||
UDPListenAddr string `gorm:"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
IsRemote int `gorm:"column:is_remote;default:0"`
|
||||
RemoteURL sql.NullString `gorm:"column:remote_url;type:text"`
|
||||
RemoteToken sql.NullString `gorm:"column:remote_token;type:text"`
|
||||
RemoteConfig sql.NullString `gorm:"column:remote_config;type:text"`
|
||||
}
|
||||
|
||||
func (Node) TableName() string { return "node" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
TunnelName string `gorm:"column:tunnel_name;type:varchar(100);not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||
|
||||
type StatisticsFlow struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID int64 `gorm:"column:user_id;not null" json:"userId"`
|
||||
Flow int64 `gorm:"not null" json:"flow"`
|
||||
TotalFlow int64 `gorm:"column:total_flow;not null" json:"totalFlow"`
|
||||
Time string `gorm:"type:varchar(100);not null" json:"time"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"-"`
|
||||
}
|
||||
|
||||
func (StatisticsFlow) TableName() string { return "statistics_flow" }
|
||||
|
||||
type Tunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
|
||||
Type int `gorm:"not null"`
|
||||
Protocol string `gorm:"type:varchar(10);not null;default:'tls'"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
InIP sql.NullString `gorm:"column:in_ip;type:text"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"`
|
||||
}
|
||||
|
||||
func (Tunnel) TableName() string { return "tunnel" }
|
||||
|
||||
type ChainTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
ChainType string `gorm:"column:chain_type;type:varchar(10);not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port sql.NullInt64 `gorm:"column:port"`
|
||||
Strategy sql.NullString `gorm:"type:varchar(10)"`
|
||||
Inx sql.NullInt64 `gorm:"column:inx"`
|
||||
Protocol sql.NullString `gorm:"type:varchar(10)"`
|
||||
}
|
||||
|
||||
func (ChainTunnel) TableName() string { return "chain_tunnel" }
|
||||
|
||||
type UserTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_user_tunnel_unique"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_user_tunnel_unique"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
Num int `gorm:"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"`
|
||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (UserTunnel) TableName() string { return "user_tunnel" }
|
||||
|
||||
type TunnelGroup struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null;uniqueIndex:idx_tunnel_group_name"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (TunnelGroup) TableName() string { return "tunnel_group" }
|
||||
|
||||
type UserGroup struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null;uniqueIndex:idx_user_group_name"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (UserGroup) TableName() string { return "user_group" }
|
||||
|
||||
type TunnelGroupTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (TunnelGroupTunnel) TableName() string { return "tunnel_group_tunnel" }
|
||||
|
||||
type UserGroupUser struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_user_group_user_unique"`
|
||||
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_user_group_user_unique"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (UserGroupUser) TableName() string { return "user_group_user" }
|
||||
|
||||
type GroupPermission struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_group_permission_unique"`
|
||||
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_unique"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (GroupPermission) TableName() string { return "group_permission" }
|
||||
|
||||
type GroupPermissionGrant struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
|
||||
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
|
||||
UserTunnelID int64 `gorm:"column:user_tunnel_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
|
||||
CreatedByGroup int `gorm:"column:created_by_group;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (GroupPermissionGrant) TableName() string { return "group_permission_grant" }
|
||||
|
||||
type ViteConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
|
||||
Value string `gorm:"type:varchar(200);not null" json:"value"`
|
||||
Time int64 `gorm:"not null" json:"time"`
|
||||
}
|
||||
|
||||
func (ViteConfig) TableName() string { return "vite_config" }
|
||||
|
||||
type Announcement struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Enabled int `gorm:"not null;default:1" json:"enabled"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"created_time"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time" json:"updated_time,omitempty"`
|
||||
}
|
||||
|
||||
func (Announcement) TableName() string { return "announcement" }
|
||||
|
||||
type SchemaVersion struct {
|
||||
Version int `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
func (SchemaVersion) TableName() string { return "schema_version" }
|
||||
|
||||
type PeerShare struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:text;not null" json:"name"`
|
||||
NodeID int64 `gorm:"column:node_id;not null" json:"nodeId"`
|
||||
Token string `gorm:"type:text;not null;uniqueIndex" json:"token"`
|
||||
MaxBandwidth int64 `gorm:"column:max_bandwidth;default:0" json:"maxBandwidth"`
|
||||
ExpiryTime int64 `gorm:"column:expiry_time;default:0" json:"expiryTime"`
|
||||
PortRangeStart int `gorm:"column:port_range_start;default:0" json:"portRangeStart"`
|
||||
PortRangeEnd int `gorm:"column:port_range_end;default:0" json:"portRangeEnd"`
|
||||
CurrentFlow int64 `gorm:"column:current_flow;default:0" json:"currentFlow"`
|
||||
IsActive int `gorm:"column:is_active;default:1" json:"isActive"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"`
|
||||
AllowedDomains string `gorm:"column:allowed_domains;type:text;default:''" json:"allowedDomains"`
|
||||
AllowedIPs string `gorm:"column:allowed_ips;type:text;default:''" json:"allowedIps"`
|
||||
}
|
||||
|
||||
func (PeerShare) TableName() string { return "peer_share" }
|
||||
|
||||
type PeerShareRuntime struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||
Role string `gorm:"type:text;not null;default:''"`
|
||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||
Port int `gorm:"not null;default:0"`
|
||||
Target string `gorm:"type:text;not null;default:''"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
|
||||
|
||||
type FederationTunnelBinding struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique;index:idx_federation_tunnel_binding_tunnel"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique"`
|
||||
ChainType int `gorm:"column:chain_type;not null;uniqueIndex:idx_federation_tunnel_binding_unique"`
|
||||
HopInx int `gorm:"column:hop_inx;not null;default:0;uniqueIndex:idx_federation_tunnel_binding_unique"`
|
||||
RemoteURL string `gorm:"column:remote_url;type:text;not null"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
RemoteBindingID string `gorm:"column:remote_binding_id;type:text;not null"`
|
||||
AllocatedPort int `gorm:"column:allocated_port;not null"`
|
||||
Status int `gorm:"not null;default:1;index:idx_federation_tunnel_binding_tunnel"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (FederationTunnelBinding) TableName() string { return "federation_tunnel_binding" }
|
||||
|
||||
// ─── Backup / Import-Export Structs ──────────────────────────────────
|
||||
// These are not GORM models; they define the JSON wire format for the
|
||||
// backup/restore API and MUST keep their existing json tags unchanged.
|
||||
|
||||
// BackupData represents the full backup structure.
|
||||
type BackupData struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
Users []UserBackup `json:"users,omitempty"`
|
||||
Nodes []NodeBackup `json:"nodes,omitempty"`
|
||||
Tunnels []TunnelBackup `json:"tunnels,omitempty"`
|
||||
Forwards []ForwardBackup `json:"forwards,omitempty"`
|
||||
UserTunnels []UserTunnelBackup `json:"userTunnels,omitempty"`
|
||||
SpeedLimits []SpeedLimitBackup `json:"speedLimits,omitempty"`
|
||||
TunnelGroups []TunnelGroupBackup `json:"tunnelGroups,omitempty"`
|
||||
UserGroups []UserGroupBackup `json:"userGroups,omitempty"`
|
||||
Permissions []PermissionBackup `json:"permissions,omitempty"`
|
||||
Configs map[string]string `json:"configs,omitempty"`
|
||||
}
|
||||
|
||||
type UserBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
User string `json:"user"`
|
||||
Pwd string `json:"pwd"`
|
||||
RoleID int `json:"roleId"`
|
||||
ExpTime int64 `json:"expTime"`
|
||||
Flow int64 `json:"flow"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
FlowResetTime int64 `json:"flowResetTime"`
|
||||
Num int `json:"num"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type NodeBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
ServerIPv6 string `json:"serverIpV6,omitempty"`
|
||||
Port string `json:"port"`
|
||||
InterfaceName string `json:"interfaceName,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
HTTP int `json:"http"`
|
||||
TLS int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
TCPListenAddr string `json:"tcpListenAddr"`
|
||||
UDPListenAddr string `json:"udpListenAddr"`
|
||||
Inx int `json:"inx"`
|
||||
IsRemote int `json:"isRemote"`
|
||||
RemoteURL string `json:"remoteUrl,omitempty"`
|
||||
RemoteToken string `json:"remoteToken,omitempty"`
|
||||
RemoteConfig string `json:"remoteConfig,omitempty"`
|
||||
}
|
||||
|
||||
type TunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrafficRatio float64 `json:"trafficRatio"`
|
||||
Type int `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
Flow int64 `json:"flow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
InIP string `json:"inIp,omitempty"`
|
||||
Inx int `json:"inx"`
|
||||
IPPreference string `json:"ipPreference,omitempty"`
|
||||
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
|
||||
}
|
||||
|
||||
type ChainTunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
ChainType string `json:"chainType"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
Inx int `json:"inx,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Strategy string `json:"strategy"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardPortBackup struct {
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type UserTunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
SpeedID int64 `json:"speedId,omitempty"`
|
||||
Num int `json:"num"`
|
||||
Flow int64 `json:"flow"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
FlowResetTime int64 `json:"flowResetTime"`
|
||||
ExpTime int64 `json:"expTime"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type SpeedLimitBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
TunnelName string `json:"tunnelName"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type TunnelGroupBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Tunnels []int64 `json:"tunnels,omitempty"`
|
||||
}
|
||||
|
||||
type UserGroupBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Users []int64 `json:"users,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserGroupID int64 `json:"userGroupId"`
|
||||
TunnelGroupID int64 `json:"tunnelGroupId"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
CreatedByGroup int `json:"createdByGroup"`
|
||||
Grants []PermissionGrantBackup `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionGrantBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserGroupID int64 `json:"userGroupId"`
|
||||
TunnelGroupID int64 `json:"tunnelGroupId"`
|
||||
UserTunnelID int64 `json:"userTunnelId"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
CreatedByGroup int `json:"createdByGroup"`
|
||||
}
|
||||
|
||||
// ImportResult contains the result of an import operation.
|
||||
type ImportResult struct {
|
||||
UsersImported int `json:"usersImported"`
|
||||
NodesImported int `json:"nodesImported"`
|
||||
TunnelsImported int `json:"tunnelsImported"`
|
||||
ForwardsImported int `json:"forwardsImported"`
|
||||
UserTunnelsImported int `json:"userTunnelsImported"`
|
||||
SpeedLimitsImported int `json:"speedLimitsImported"`
|
||||
TunnelGroupsImported int `json:"tunnelGroupsImported"`
|
||||
UserGroupsImported int `json:"userGroupsImported"`
|
||||
PermissionsImported int `json:"permissionsImported"`
|
||||
ConfigsImported int `json:"configsImported"`
|
||||
AutoBackup *BackupData `json:"autoBackup,omitempty"`
|
||||
}
|
||||
|
||||
// ─── View Structs (used by Repository, not GORM models) ─────────────
|
||||
// These are used for JOIN query results that don't map 1:1 to a table.
|
||||
|
||||
// ForwardRecord is a minimal forward view used by control plane and flow policy.
|
||||
type ForwardRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
}
|
||||
|
||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||
type TunnelRecord struct {
|
||||
ID int64
|
||||
Type int
|
||||
Status int
|
||||
Flow int64
|
||||
TrafficRatio float64
|
||||
}
|
||||
|
||||
// ForwardPortRecord is a forward port mapping used by control plane.
|
||||
type ForwardPortRecord struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
}
|
||||
|
||||
// NodeRecord is a node view used by control plane.
|
||||
type NodeRecord struct {
|
||||
ID int64
|
||||
Name string
|
||||
ServerIP string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
Status int
|
||||
PortRange string
|
||||
TCPListenAddr string
|
||||
UDPListenAddr string
|
||||
InterfaceName string
|
||||
IsRemote int
|
||||
RemoteURL string
|
||||
RemoteToken string
|
||||
RemoteConfig string
|
||||
}
|
||||
|
||||
type ChainNodeRecord struct {
|
||||
ChainType int
|
||||
Inx int64
|
||||
NodeID int64
|
||||
Port int
|
||||
NodeName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
}
|
||||
|
||||
type UserTunnelLimiterInfo struct {
|
||||
UserTunnelID int64
|
||||
LimiterID *int64
|
||||
Speed *int
|
||||
}
|
||||
|
||||
// UserFlowSnapshot holds a user's current flow counters (used by stats job).
|
||||
type UserFlowSnapshot struct {
|
||||
UserID int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
}
|
||||
|
||||
// ExpiredUserTunnel holds minimal info for an expired user_tunnel row.
|
||||
type ExpiredUserTunnel struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
}
|
||||
|
||||
// UserTunnelDetail is a joined view of user_tunnel + tunnel + speed_limit.
|
||||
type UserTunnelDetail struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TunnelFlow int
|
||||
Flow int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
Num int
|
||||
FlowResetTime int64
|
||||
ExpTime int64
|
||||
SpeedID sql.NullInt64
|
||||
SpeedLimit sql.NullString
|
||||
Speed sql.NullInt64
|
||||
}
|
||||
|
||||
// UserForwardDetail is a joined view of forward + tunnel.
|
||||
type UserForwardDetail struct {
|
||||
ID int64
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
InIP string
|
||||
InPort sql.NullInt64
|
||||
RemoteAddr string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
Status int
|
||||
CreatedAt int64
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed sql/schema.sql
|
||||
var EmbeddedSchema string
|
||||
|
||||
//go:embed sql/data.sql
|
||||
var EmbeddedSeedData string
|
||||
@@ -1,18 +0,0 @@
|
||||
INSERT INTO "user" (id, "user", pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO vite_config (id, name, value, time)
|
||||
VALUES (1, 'app_name', 'flux', 1755147963000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.user_id_seq') IS NOT NULL THEN
|
||||
PERFORM setval('user_id_seq', (SELECT COALESCE(MAX(id), 0) FROM "user"));
|
||||
END IF;
|
||||
IF to_regclass('public.vite_config_id_seq') IS NOT NULL THEN
|
||||
PERFORM setval('vite_config_id_seq', (SELECT COALESCE(MAX(id), 0) FROM vite_config));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -1,241 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS forward (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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 BIGINT NOT NULL DEFAULT 0,
|
||||
out_flow BIGINT NOT NULL DEFAULT 0,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward_port (
|
||||
id SERIAL PRIMARY KEY,
|
||||
forward_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
server_ip_v4 VARCHAR(100),
|
||||
server_ip_v6 VARCHAR(100),
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
is_remote INTEGER DEFAULT 0,
|
||||
remote_url TEXT,
|
||||
remote_token TEXT,
|
||||
remote_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
tunnel_name VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statistics_flow (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
flow BIGINT NOT NULL,
|
||||
total_flow BIGINT NOT NULL,
|
||||
time VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow BIGINT NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
chain_type VARCHAR(10) NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER,
|
||||
strategy VARCHAR(10),
|
||||
inx INTEGER,
|
||||
protocol VARCHAR(10)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"user" VARCHAR(100) NOT NULL,
|
||||
pwd VARCHAR(100) NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
exp_time BIGINT NOT NULL,
|
||||
flow BIGINT NOT NULL,
|
||||
in_flow BIGINT NOT NULL DEFAULT 0,
|
||||
out_flow BIGINT NOT NULL DEFAULT 0,
|
||||
flow_reset_time BIGINT NOT NULL,
|
||||
num INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
speed_id INTEGER,
|
||||
num INTEGER NOT NULL,
|
||||
flow BIGINT NOT NULL,
|
||||
in_flow BIGINT NOT NULL DEFAULT 0,
|
||||
out_flow BIGINT NOT NULL DEFAULT 0,
|
||||
flow_reset_time BIGINT NOT NULL,
|
||||
exp_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission_grant (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
user_tunnel_id INTEGER NOT NULL,
|
||||
created_by_group INTEGER NOT NULL DEFAULT 0,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vite_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
max_bandwidth INTEGER DEFAULT 0,
|
||||
expiry_time BIGINT DEFAULT 0,
|
||||
port_range_start INTEGER DEFAULT 0,
|
||||
port_range_end INTEGER DEFAULT 0,
|
||||
current_flow BIGINT DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
allowed_domains TEXT DEFAULT '',
|
||||
allowed_ips TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share_runtime (
|
||||
id SERIAL PRIMARY KEY,
|
||||
share_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
reservation_id TEXT NOT NULL UNIQUE,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
binding_id TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
chain_name TEXT NOT NULL DEFAULT '',
|
||||
service_name TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tls',
|
||||
strategy TEXT NOT NULL DEFAULT 'round',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
applied INTEGER NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
chain_type INTEGER NOT NULL,
|
||||
hop_inx INTEGER NOT NULL DEFAULT 0,
|
||||
remote_url TEXT NOT NULL,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
remote_binding_id TEXT NOT NULL,
|
||||
allocated_port INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) UserTunnelExistsByUserAndTunnel(userID, tunnelID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ports []model.ForwardPort
|
||||
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardPortRecord, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
}
|
||||
var ct model.ChainTunnel
|
||||
err := r.db.Select("protocol").
|
||||
Where("tunnel_id = ? AND chain_type = ?", tunnelID, "3").
|
||||
Order("id ASC").
|
||||
Take(&ct).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if ct.Protocol.Valid {
|
||||
return ct.Protocol.String, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeRecord(nodeID int64) (*model.NodeRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var n model.Node
|
||||
err := r.db.Where("id = ?", nodeID).First(&n).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodeRecordFromModel(&n), nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeRecordTx(tx *gorm.DB, nodeID int64) (*model.NodeRecord, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var n model.Node
|
||||
err := tx.Where("id = ?", nodeID).First(&n).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodeRecordFromModel(&n), nil
|
||||
}
|
||||
|
||||
func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
rec := &model.NodeRecord{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
ServerIP: n.ServerIP,
|
||||
Status: n.Status,
|
||||
PortRange: n.Port,
|
||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||
IsRemote: n.IsRemote,
|
||||
}
|
||||
if n.ServerIPV4.Valid {
|
||||
rec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)
|
||||
}
|
||||
if n.ServerIPV6.Valid {
|
||||
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
|
||||
}
|
||||
if n.InterfaceName.Valid {
|
||||
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
|
||||
}
|
||||
if n.RemoteURL.Valid {
|
||||
rec.RemoteURL = strings.TrimSpace(n.RemoteURL.String)
|
||||
}
|
||||
if n.RemoteToken.Valid {
|
||||
rec.RemoteToken = strings.TrimSpace(n.RemoteToken.String)
|
||||
}
|
||||
if n.RemoteConfig.Valid {
|
||||
rec.RemoteConfig = strings.TrimSpace(n.RemoteConfig.String)
|
||||
}
|
||||
if rec.TCPListenAddr == "" {
|
||||
rec.TCPListenAddr = "[::]"
|
||||
}
|
||||
if rec.UDPListenAddr == "" {
|
||||
rec.UDPListenAddr = "[::]"
|
||||
}
|
||||
if strings.TrimSpace(rec.Name) == "" {
|
||||
rec.Name = fmt.Sprintf("node_%d", rec.ID)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *Repository) ResolveUserTunnelAndLimiter(userID, tunnelID int64) (*model.UserTunnelLimiterInfo, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
type row struct {
|
||||
UserTunnelID int64 `gorm:"column:user_tunnel_id"`
|
||||
LimiterID sql.NullInt64 `gorm:"column:limiter_id"`
|
||||
Speed sql.NullInt64 `gorm:"column:speed"`
|
||||
}
|
||||
var rec row
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Select("user_tunnel.id AS user_tunnel_id, speed_limit.id AS limiter_id, speed_limit.speed AS speed").
|
||||
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
|
||||
Where("user_tunnel.user_id = ? AND user_tunnel.tunnel_id = ?", userID, tunnelID).
|
||||
Order("user_tunnel.id ASC").
|
||||
Limit(1).
|
||||
Take(&rec).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &model.UserTunnelLimiterInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
info := &model.UserTunnelLimiterInfo{UserTunnelID: rec.UserTunnelID}
|
||||
if rec.LimiterID.Valid && rec.LimiterID.Int64 > 0 {
|
||||
v := rec.LimiterID.Int64
|
||||
info.LimiterID = &v
|
||||
s := int(rec.Speed.Int64)
|
||||
info.Speed = &s
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).
|
||||
Order("id ASC").Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListUserTunnelIDsByUser(userID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Where("user_id = ?", userID).
|
||||
Order("id ASC").Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelName(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
}
|
||||
var name string
|
||||
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Pluck("name", &name).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
type row struct {
|
||||
ChainType string
|
||||
Inx sql.NullInt64
|
||||
NodeID int64
|
||||
Port sql.NullInt64
|
||||
Name sql.NullString
|
||||
Protocol sql.NullString
|
||||
Strategy sql.NullString
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy").
|
||||
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
|
||||
Where("chain_tunnel.tunnel_id = ?", tunnelID).
|
||||
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.ChainNodeRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
chainType := 0
|
||||
if v := strings.TrimSpace(row.ChainType); v != "" {
|
||||
if parsed, parseErr := strconv.Atoi(v); parseErr == nil {
|
||||
chainType = parsed
|
||||
}
|
||||
}
|
||||
inx := int64(0)
|
||||
if row.Inx.Valid {
|
||||
inx = row.Inx.Int64
|
||||
}
|
||||
port := 0
|
||||
if row.Port.Valid {
|
||||
port = int(row.Port.Int64)
|
||||
}
|
||||
item := model.ChainNodeRecord{
|
||||
ChainType: chainType,
|
||||
Inx: inx,
|
||||
NodeID: row.NodeID,
|
||||
Port: port,
|
||||
}
|
||||
if strings.TrimSpace(row.Name.String) == "" {
|
||||
item.NodeName = fmt.Sprintf("node_%d", row.NodeID)
|
||||
} else {
|
||||
item.NodeName = row.Name.String
|
||||
}
|
||||
if strings.TrimSpace(row.Protocol.String) == "" {
|
||||
item.Protocol = "tls"
|
||||
} else {
|
||||
item.Protocol = row.Protocol.String
|
||||
}
|
||||
if strings.TrimSpace(row.Strategy.String) == "" {
|
||||
item.Strategy = "round"
|
||||
} else {
|
||||
item.Strategy = row.Strategy.String
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RemoteNodeRow holds the columns fetched for a remote node listing.
|
||||
type RemoteNodeRow struct {
|
||||
ID int64
|
||||
Name string
|
||||
RemoteURL sql.NullString
|
||||
RemoteToken sql.NullString
|
||||
RemoteConfig sql.NullString
|
||||
}
|
||||
|
||||
// NodeBasicInfo holds name, server_ip, and status for a node.
|
||||
type NodeBasicInfo struct {
|
||||
Name string
|
||||
ServerIP string
|
||||
Status int
|
||||
}
|
||||
|
||||
// FederationBindingRow holds the columns for an active federation tunnel binding.
|
||||
type FederationBindingRow struct {
|
||||
ID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
ChainType int
|
||||
HopInx int
|
||||
AllocatedPort int
|
||||
ResourceKey string
|
||||
RemoteBindingID string
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
|
||||
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []RemoteNodeRow
|
||||
err := r.db.Model(&model.Node{}).
|
||||
Select("id, name, remote_url, remote_token, remote_config").
|
||||
Where("is_remote = 1").
|
||||
Order("id DESC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]RemoteNodeRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateNodeRemoteConfig sets the remote_config JSON for a given node.
|
||||
func (r *Repository) UpdateNodeRemoteConfig(nodeID int64, configJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.Node{}).Where("id = ?", nodeID).Update("remote_config", configJSON).Error
|
||||
}
|
||||
|
||||
// ListActiveBindingsForNode returns active federation tunnel bindings for a node.
|
||||
func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindingRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []FederationBindingRow
|
||||
err := r.db.Model(&model.FederationTunnelBinding{}).
|
||||
Select("federation_tunnel_binding.id, federation_tunnel_binding.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, federation_tunnel_binding.chain_type, federation_tunnel_binding.hop_inx, federation_tunnel_binding.allocated_port, federation_tunnel_binding.resource_key, federation_tunnel_binding.remote_binding_id, federation_tunnel_binding.updated_time").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = federation_tunnel_binding.tunnel_id").
|
||||
Where("federation_tunnel_binding.node_id = ? AND federation_tunnel_binding.status = 1", nodeID).
|
||||
Order("federation_tunnel_binding.allocated_port ASC, federation_tunnel_binding.id ASC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]FederationBindingRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
|
||||
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var n model.Node
|
||||
err := r.db.Select("name", "server_ip", "status").Where("id = ?", nodeID).First(&n).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NodeBasicInfo{Name: n.Name, ServerIP: n.ServerIP, Status: n.Status}, nil
|
||||
}
|
||||
|
||||
// CreateFederationTunnel creates a tunnel and chain_tunnel entry in a transaction,
|
||||
// returning the new tunnel ID.
|
||||
func (r *Repository) CreateFederationTunnel(name string, tunnelType int, protocol string, now int64, nodeID int64, remotePort int) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
tunnel := model.Tunnel{
|
||||
Name: name,
|
||||
Type: tunnelType,
|
||||
Protocol: protocol,
|
||||
Flow: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
InIP: sql.NullString{String: "", Valid: false},
|
||||
}
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&tunnel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ct := model.ChainTunnel{
|
||||
TunnelID: tunnel.ID,
|
||||
ChainType: "1",
|
||||
NodeID: nodeID,
|
||||
Port: sql.NullInt64{Int64: int64(remotePort), Valid: true},
|
||||
Strategy: sql.NullString{String: "fifo", Valid: true},
|
||||
Inx: sql.NullInt64{Int64: 0, Valid: true},
|
||||
Protocol: sql.NullString{String: protocol, Valid: true},
|
||||
}
|
||||
if err := tx.Create(&ct).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tunnel.ID, nil
|
||||
}
|
||||
|
||||
// ListUsedPortsOnNode returns all ports in use on a given node from chain_tunnel and forward_port tables.
|
||||
func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
used := make(map[int]struct{})
|
||||
|
||||
var chainPorts []int
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Where("node_id = ? AND port > 0", nodeID).
|
||||
Pluck("port", &chainPorts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range chainPorts {
|
||||
if p > 0 {
|
||||
used[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var forwardPorts []int
|
||||
err = r.db.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port > 0", nodeID).
|
||||
Pluck("port", &forwardPorts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range forwardPorts {
|
||||
if p > 0 {
|
||||
used[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]int, 0, len(used))
|
||||
for p := range used {
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListTunnelIDsByNamePrefix returns all tunnel IDs whose name starts with the given prefix.
|
||||
func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.Tunnel{}).
|
||||
Where("name LIKE ?", prefix+"%").
|
||||
Order("id ASC").
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ids == nil {
|
||||
ids = make([]int64, 0)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// NextIndex returns COALESCE(MAX(inx), -1) + 1 for the given table.
|
||||
func (r *Repository) NextIndex(table string) int {
|
||||
if r == nil || r.db == nil {
|
||||
return 0
|
||||
}
|
||||
var modelRef interface{}
|
||||
switch table {
|
||||
case "node":
|
||||
modelRef = &model.Node{}
|
||||
case "tunnel":
|
||||
modelRef = &model.Tunnel{}
|
||||
case "forward":
|
||||
modelRef = &model.Forward{}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
type inxRow struct {
|
||||
Inx int
|
||||
}
|
||||
var row inxRow
|
||||
err := r.db.Model(modelRef).
|
||||
Select("inx").
|
||||
Order("inx DESC").
|
||||
Limit(1).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if row.Inx < 0 {
|
||||
return 0
|
||||
}
|
||||
return row.Inx + 1
|
||||
}
|
||||
|
||||
// CreateRemoteNode inserts a new remote node.
|
||||
func (r *Repository) CreateRemoteNode(name, secret, serverIP, portRange string, now int64, status int, inx int, remoteURL, remoteToken, remoteConfigJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
node := model.Node{
|
||||
Name: name,
|
||||
Secret: secret,
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: sql.NullString{},
|
||||
ServerIPV6: sql.NullString{},
|
||||
Port: portRange,
|
||||
InterfaceName: sql.NullString{},
|
||||
Version: sql.NullString{},
|
||||
HTTP: 0,
|
||||
TLS: 0,
|
||||
Socks: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
TCPListenAddr: "[::]",
|
||||
UDPListenAddr: "[::]",
|
||||
Inx: inx,
|
||||
IsRemote: 1,
|
||||
RemoteURL: sql.NullString{String: remoteURL, Valid: remoteURL != ""},
|
||||
RemoteToken: sql.NullString{String: remoteToken, Valid: remoteToken != ""},
|
||||
RemoteConfig: sql.NullString{String: remoteConfigJSON, Valid: remoteConfigJSON != ""},
|
||||
}
|
||||
return r.db.Create(&node).Error
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) UpdateForwardStatus(forwardID int64, status int, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{
|
||||
"status": status, "updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("user_id = ? AND status = 1", userID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var f model.Forward
|
||||
err := r.db.Where("id = ?", forwardID).First(&f).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fr := model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
}
|
||||
return &fr, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var t model.Tunnel
|
||||
err := r.db.Where("id = ?", tunnelID).First(&t).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tr := model.TunnelRecord{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Status: t.Status,
|
||||
Flow: t.Flow,
|
||||
TrafficRatio: t.TrafficRatio,
|
||||
}
|
||||
if tr.Flow <= 0 {
|
||||
tr.Flow = 1
|
||||
}
|
||||
if tr.TrafficRatio <= 0 {
|
||||
tr.TrafficRatio = 1
|
||||
}
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
func (r *Repository) TunnelExists(tunnelID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.SpeedLimit{}).Where("id = ?", id).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// ─── Semantic Group Queries (replacing QueryInt64List/QueryPairs passthrough) ─
|
||||
|
||||
// ListUserIDsByUserGroup returns all user IDs belonging to a user group.
|
||||
func (r *Repository) ListUserIDsByUserGroup(userGroupID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserGroupUser{}).
|
||||
Where("user_group_id = ?", userGroupID).
|
||||
Pluck("user_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ListTunnelIDsByTunnelGroup returns all tunnel IDs belonging to a tunnel group.
|
||||
func (r *Repository) ListTunnelIDsByTunnelGroup(tunnelGroupID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.TunnelGroupTunnel{}).
|
||||
Where("tunnel_group_id = ?", tunnelGroupID).
|
||||
Pluck("tunnel_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ListGroupPermissionPairsByUserGroup returns [userGroupID, tunnelGroupID] pairs
|
||||
// for all group permissions associated with a user group.
|
||||
func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][2]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var perms []model.GroupPermission
|
||||
err := r.db.Where("user_group_id = ?", userGroupID).Find(&perms).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([][2]int64, len(perms))
|
||||
for i, p := range perms {
|
||||
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ListGroupPermissionPairsByTunnelGroup returns [userGroupID, tunnelGroupID] pairs
|
||||
// for all group permissions associated with a tunnel group.
|
||||
func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var perms []model.GroupPermission
|
||||
err := r.db.Where("tunnel_group_id = ?", tunnelGroupID).Find(&perms).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([][2]int64, len(perms))
|
||||
for i, p := range perms {
|
||||
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
gsqlite "github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
called := 0
|
||||
original := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = original
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
if called != 1 {
|
||||
t.Fatalf("expected postgres id repair to run once, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
wantErr := errors.New("repair failed")
|
||||
original := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = original
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
INSERT OR IGNORE INTO user (id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
|
||||
|
||||
INSERT OR IGNORE INTO vite_config (id, name, value, time)
|
||||
VALUES (1, 'app_name', 'flux', 1755147963000);
|
||||
@@ -1,245 +0,0 @@
|
||||
-- SQLite Auto-generated schema
|
||||
-- This will be executed automatically on startup if tables don't exist
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward_port (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
forward_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
server_ip_v4 VARCHAR(100),
|
||||
server_ip_v6 VARCHAR(100),
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
is_remote INTEGER DEFAULT 0,
|
||||
remote_url TEXT,
|
||||
remote_token TEXT,
|
||||
remote_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
tunnel_name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statistics_flow (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
total_flow INTEGER NOT NULL,
|
||||
time VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_id INTEGER NOT NULL ,
|
||||
chain_type VARCHAR(10) NOT NULL,
|
||||
node_id INTEGER NOT NULL ,
|
||||
port INTEGER,
|
||||
strategy VARCHAR(10),
|
||||
inx INTEGER,
|
||||
protocol VARCHAR(10)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user VARCHAR(100) NOT NULL,
|
||||
pwd VARCHAR(100) NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
exp_time INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
flow_reset_time INTEGER NOT NULL,
|
||||
num INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
speed_id INTEGER,
|
||||
num INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
flow_reset_time INTEGER NOT NULL,
|
||||
exp_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission_grant (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
user_tunnel_id INTEGER NOT NULL,
|
||||
created_by_group INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vite_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
max_bandwidth INTEGER DEFAULT 0,
|
||||
expiry_time INTEGER DEFAULT 0,
|
||||
port_range_start INTEGER DEFAULT 0,
|
||||
port_range_end INTEGER DEFAULT 0,
|
||||
current_flow INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
allowed_domains TEXT DEFAULT '',
|
||||
allowed_ips TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share_runtime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
reservation_id TEXT NOT NULL UNIQUE,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
binding_id TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
chain_name TEXT NOT NULL DEFAULT '',
|
||||
service_name TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tls',
|
||||
strategy TEXT NOT NULL DEFAULT 'round',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
applied INTEGER NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
chain_type INTEGER NOT NULL,
|
||||
hop_inx INTEGER NOT NULL DEFAULT 0,
|
||||
remote_url TEXT NOT NULL,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
remote_binding_id TEXT NOT NULL,
|
||||
allocated_port INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type encryptedMessage struct {
|
||||
@@ -68,7 +68,7 @@ type CommandResult struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
repo *sqlite.Repository
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
@@ -79,7 +79,7 @@ type Server struct {
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
func NewServer(repo *sqlite.Repository, jwtSecret string) *Server {
|
||||
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
||||
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
||||
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
|
||||
t.Helper()
|
||||
var v int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryInt64(t *testing.T, r *repo.Repository, query string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var v int64
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query int64 failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryString(t *testing.T, r *repo.Repository, query string, args ...interface{}) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query string failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryInt64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int) {
|
||||
t.Helper()
|
||||
var a int64
|
||||
var b int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b); err != nil {
|
||||
t.Fatalf("query int64+int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
func tryQueryString(t *testing.T, r *repo.Repository, query string, args ...interface{}) (string, error) {
|
||||
t.Helper()
|
||||
var v string
|
||||
err := r.DB().Raw(query, args...).Row().Scan(&v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func mustQueryNullString(t *testing.T, r *repo.Repository, query string, args ...interface{}) sql.NullString {
|
||||
t.Helper()
|
||||
var v sql.NullString
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query null string failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryTwoNullStrings(t *testing.T, r *repo.Repository, query string, args ...interface{}) (sql.NullString, sql.NullString) {
|
||||
t.Helper()
|
||||
var a sql.NullString
|
||||
var b sql.NullString
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b); err != nil {
|
||||
t.Fatalf("query two null strings failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
func mustQueryNodePorts(t *testing.T, r *repo.Repository, query string, args ...interface{}) map[int64]int {
|
||||
t.Helper()
|
||||
rows, err := r.DB().Raw(query, args...).Rows()
|
||||
if err != nil {
|
||||
t.Fatalf("query node ports failed: %v (query=%q)", err, query)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[int64]int)
|
||||
for rows.Next() {
|
||||
var nodeID int64
|
||||
var port int
|
||||
if err := rows.Scan(&nodeID, &port); err != nil {
|
||||
t.Fatalf("scan node ports row failed: %v (query=%q)", err, query)
|
||||
}
|
||||
out[nodeID] = port
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate node ports rows failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tryQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int, error) {
|
||||
t.Helper()
|
||||
var v int
|
||||
err := r.DB().Raw(query, args...).Row().Scan(&v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -16,82 +16,69 @@ import (
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-chain-tunnel")
|
||||
|
||||
insertNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertNode("entry-node", "10.0.1.10")
|
||||
chainNodeID := insertNode("chain-node", "10.0.1.20")
|
||||
exitNodeID := insertNode("exit-node", "10.0.1.30")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, chainNodeID); err != nil {
|
||||
`, tunnelID, chainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID); err != nil {
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0)
|
||||
if err != nil {
|
||||
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, err := forwardRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get forward id: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "chain-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
@@ -208,7 +195,7 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
remoteToken := "remote-diagnose-token"
|
||||
@@ -256,67 +243,53 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
defer remoteServer.Close()
|
||||
|
||||
insertLocalNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert local node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get local node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
insertRemoteNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?, ?, 1, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get remote node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertLocalNode("entry-local", "10.50.0.10")
|
||||
remoteChainNodeID := insertRemoteNode("middle-remote", "10.50.0.20")
|
||||
exitNodeID := insertLocalNode("exit-local", "10.50.0.30")
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-remote-tunnel")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, remoteChainNodeID); err != nil {
|
||||
`, tunnelID, remoteChainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID); err != nil {
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
@@ -409,17 +382,17 @@ func valueAsBool(v interface{}) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func setupDiagnosisContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
func setupDiagnosisContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "diagnosis-contract.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
h := handler.New(r, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), r
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
@@ -39,7 +39,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||
|
||||
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
entryShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-token",
|
||||
@@ -49,7 +49,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
middleShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-token",
|
||||
@@ -59,7 +59,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
exitShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-token",
|
||||
@@ -112,10 +112,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id (%s): %v", name, err)
|
||||
}
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
@@ -191,7 +188,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-dx", "203.0.113.12", "54000-54010", "provider-middle-dx-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-dx", "203.0.113.13", "55000-55010", "provider-exit-dx-secret", 1)
|
||||
|
||||
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
entryShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share-dx",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-dx-token",
|
||||
@@ -201,7 +198,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
middleShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share-dx",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-dx-token",
|
||||
@@ -211,7 +208,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
exitShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share-dx",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-dx-token",
|
||||
@@ -261,10 +258,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
consumerRouter.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote").Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote")
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id")
|
||||
}
|
||||
@@ -335,7 +329,7 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
|
||||
|
||||
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share-rt",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-rt-token",
|
||||
@@ -345,7 +339,7 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share-rt",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-rt-token",
|
||||
@@ -355,7 +349,7 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share-rt",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-rt-token",
|
||||
@@ -414,10 +408,7 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id (%s): %v", name, err)
|
||||
}
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
@@ -446,32 +437,27 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
createTunnel("dual-panel-remote-entry-offline")
|
||||
}
|
||||
|
||||
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
func insertPeerShare(t *testing.T, repo *sqlite.Repository, share *sqlite.PeerShare) int64 {
|
||||
func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 {
|
||||
t.Helper()
|
||||
if share == nil {
|
||||
t.Fatalf("share is nil")
|
||||
}
|
||||
if err := repo.CreatePeerShare(share); err != nil {
|
||||
if err := r.CreatePeerShare(share); err != nil {
|
||||
t.Fatalf("create peer share %s: %v", share.Name, err)
|
||||
}
|
||||
saved, err := repo.GetPeerShareByToken(share.Token)
|
||||
saved, err := r.GetPeerShareByToken(share.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("query peer share %s: %v", share.Name, err)
|
||||
}
|
||||
@@ -498,41 +484,26 @@ func importRemoteNodeForContract(t *testing.T, router http.Handler, adminToken,
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
|
||||
func queryRemoteNodeIDByToken(t *testing.T, repo *sqlite.Repository, token string) int64 {
|
||||
func queryRemoteNodeIDByToken(t *testing.T, r *repo.Repository, token string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token).Scan(&id); err != nil {
|
||||
t.Fatalf("query remote node by token %s: %v", token, err)
|
||||
}
|
||||
id := mustQueryInt64(t, r, `SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token)
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid remote node id for token %s", token)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func assertTunnelPortInRange(t *testing.T, repo *sqlite.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
|
||||
func assertTunnelPortInRange(t *testing.T, r *repo.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
|
||||
t.Helper()
|
||||
var port int
|
||||
err := repo.DB().QueryRow(`
|
||||
SELECT port
|
||||
FROM chain_tunnel
|
||||
WHERE tunnel_id = ? AND chain_type = ? AND node_id = ?
|
||||
LIMIT 1
|
||||
`, tunnelID, chainType, nodeID).Scan(&port)
|
||||
if err != nil {
|
||||
t.Fatalf("query tunnel=%d chainType=%d node=%d port: %v", tunnelID, chainType, nodeID, err)
|
||||
}
|
||||
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = ? AND node_id = ? LIMIT 1`, tunnelID, chainType, nodeID)
|
||||
if port < minPort || port > maxPort {
|
||||
t.Fatalf("expected port in range [%d,%d], got %d", minPort, maxPort, port)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interface{}, expected int) {
|
||||
func assertCount(t *testing.T, r *repo.Repository, query string, arg interface{}, expected int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := repo.DB().QueryRow(query, arg).Scan(&got); err != nil {
|
||||
t.Fatalf("count query failed: %v", err)
|
||||
}
|
||||
got := mustQueryInt(t, r, query, arg)
|
||||
if got != expected {
|
||||
t.Fatalf("expected count %d, got %d (query: %s, arg: %v)", expected, got, query, arg)
|
||||
}
|
||||
@@ -638,12 +609,12 @@ func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret strin
|
||||
}
|
||||
}
|
||||
|
||||
func waitNodeStatus(t *testing.T, repo *sqlite.Repository, nodeID int64, expectedStatus int) {
|
||||
func waitNodeStatus(t *testing.T, r *repo.Repository, nodeID int64, expectedStatus int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
var status int
|
||||
if err := repo.DB().QueryRow(`SELECT status FROM node WHERE id = ?`, nodeID).Scan(&status); err == nil && status == expectedStatus {
|
||||
status, err := tryQueryInt(t, r, `SELECT status FROM node WHERE id = ?`, nodeID)
|
||||
if err == nil && status == expectedStatus {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
@@ -688,3 +659,112 @@ func valueAsBool(v interface{}) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
|
||||
providerSecret := "provider-portrange-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
providerNodeID := insertContractNode(t, providerRepo, "provider-portrange-node", "198.51.100.50", "44000-44010", "provider-portrange-secret", 1)
|
||||
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "portrange-share",
|
||||
NodeID: providerNodeID,
|
||||
Token: "share-portrange-token",
|
||||
PortRangeStart: 44000,
|
||||
PortRangeEnd: 44010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
stopNode := startMockNodeSession(t, providerServer.URL, "provider-portrange-secret")
|
||||
defer stopNode()
|
||||
|
||||
sendCommand := func(token string, cmdType string, data interface{}) *httptest.ResponseRecorder {
|
||||
payload := map[string]interface{}{
|
||||
"commandType": cmdType,
|
||||
"data": data,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal command payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
providerRouter.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
|
||||
// Test: AddService with port OUTSIDE allowed range should be rejected
|
||||
outOfRangeData := map[string]interface{}{
|
||||
"services": []map[string]interface{}{
|
||||
{
|
||||
"name": "test_service_tcp",
|
||||
"addr": "[::]:55555",
|
||||
"handler": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
res := sendCommand("share-portrange-token", "AddService", outOfRangeData)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 403 {
|
||||
t.Fatalf("expected code 403 for out-of-range port, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
// Test: UpdateService with port OUTSIDE allowed range should be rejected
|
||||
res = sendCommand("share-portrange-token", "UpdateService", outOfRangeData)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 403 {
|
||||
t.Fatalf("expected code 403 for out-of-range UpdateService, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
// Test: AddService with port INSIDE allowed range should succeed
|
||||
inRangeData := map[string]interface{}{
|
||||
"services": []map[string]interface{}{
|
||||
{
|
||||
"name": "test_service_ok_tcp",
|
||||
"addr": "[::]:44005",
|
||||
"handler": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
res = sendCommand("share-portrange-token", "AddService", inRangeData)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0 for in-range port, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
// Test: Non-service commands should pass through without port validation
|
||||
res = sendCommand("share-portrange-token", "reload", nil)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0 for reload command, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,67 +18,51 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
|
||||
|
||||
nodeRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "entry-node", "entry-secret", "10.0.0.10", "10.0.0.10", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, "entry-node", "entry-secret", "10.0.0.10", "10.0.0.10", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
entryNodeID, err := nodeRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "entry-node")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
resAdmin, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 1, "admin_user", "admin-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0)
|
||||
if err != nil {
|
||||
`, 1, "admin_user", "admin-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert admin forward: %v", err)
|
||||
}
|
||||
adminForwardID, err := resAdmin.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get admin forward id: %v", err)
|
||||
}
|
||||
adminForwardID := mustLastInsertID(t, repo, "admin-forward")
|
||||
|
||||
resUser, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "user-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 1)
|
||||
if err != nil {
|
||||
`, 2, "normal_user", "user-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 1).Error; err != nil {
|
||||
t.Fatalf("insert user forward: %v", err)
|
||||
}
|
||||
userForwardID, err := resUser.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get user forward id: %v", err)
|
||||
}
|
||||
userForwardID := mustLastInsertID(t, repo, "user-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
@@ -207,41 +191,31 @@ func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
insertTunnel := func(name string, inx int) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, 1.0, 1, "tls", 99999, now, now, 1, nil, inx)
|
||||
if err != nil {
|
||||
`, name, 1.0, 1, "tls", 99999, now, now, 1, nil, inx).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string, inx int) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
tunnelA := insertTunnel("switch-tunnel-a", 0)
|
||||
@@ -249,45 +223,41 @@ func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
|
||||
nodeA := insertNode("switch-node-a", "10.10.0.1", "21000-21010", 0)
|
||||
nodeB := insertNode("switch-node-b", "10.10.0.2", "22000-22010", 1)
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 21001, 'round', 1, 'tls')
|
||||
`, tunnelA, nodeA); err != nil {
|
||||
`, tunnelA, nodeA).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel tunnelA: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 22001, 'round', 1, 'tls')
|
||||
`, tunnelB, nodeB); err != nil {
|
||||
`, tunnelB, nodeB).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel tunnelB: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelA); err != nil {
|
||||
`, tunnelA).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel A: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(11, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelB); err != nil {
|
||||
`, tunnelB).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel B: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'switch_user', 'switch-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelA, now, now)
|
||||
if err != nil {
|
||||
`, tunnelA, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, err := forwardRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get forward id: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, repo, "switch-forward")
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 21001); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 21001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
@@ -307,19 +277,12 @@ func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
|
||||
t.Fatalf("expected update failure when node is offline")
|
||||
}
|
||||
|
||||
var tunnelAfter int64
|
||||
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
|
||||
t.Fatalf("query forward tunnel_id: %v", err)
|
||||
}
|
||||
tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
|
||||
if tunnelAfter != tunnelA {
|
||||
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
|
||||
}
|
||||
|
||||
var nodeAfter int64
|
||||
var portAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
|
||||
t.Fatalf("query forward_port: %v", err)
|
||||
}
|
||||
nodeAfter, portAfter := mustQueryInt64Int(t, repo, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if nodeAfter != nodeA || portAfter != 21001 {
|
||||
t.Fatalf("expected forward_port rollback to node=%d port=21001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
|
||||
}
|
||||
@@ -335,73 +298,68 @@ func TestForwardBatchChangeTunnelRollbackOnSyncFailure(t *testing.T) {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'batch_switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelResA, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('batch-switch-tunnel-a', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel A: %v", err)
|
||||
}
|
||||
tunnelA, _ := tunnelResA.LastInsertId()
|
||||
tunnelA := mustLastInsertID(t, repo, "batch-switch-tunnel-a")
|
||||
|
||||
tunnelResB, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('batch-switch-tunnel-b', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 1)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel B: %v", err)
|
||||
}
|
||||
tunnelB, _ := tunnelResB.LastInsertId()
|
||||
tunnelB := mustLastInsertID(t, repo, "batch-switch-tunnel-b")
|
||||
|
||||
nodeResA, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES('batch-switch-node-a', 'batch-switch-node-a-secret', '10.11.0.1', '10.11.0.1', '', '23000-23010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node A: %v", err)
|
||||
}
|
||||
nodeA, _ := nodeResA.LastInsertId()
|
||||
nodeA := mustLastInsertID(t, repo, "batch-switch-node-a")
|
||||
|
||||
nodeResB, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES('batch-switch-node-b', 'batch-switch-node-b-secret', '10.11.0.2', '10.11.0.2', '', '24000-24010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 1)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node B: %v", err)
|
||||
}
|
||||
nodeB, _ := nodeResB.LastInsertId()
|
||||
nodeB := mustLastInsertID(t, repo, "batch-switch-node-b")
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 23001, 'round', 1, 'tls')`, tunnelA, nodeA); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 23001, 'round', 1, 'tls')`, tunnelA, nodeA).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel A: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 24001, 'round', 1, 'tls')`, tunnelB, nodeB); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 24001, 'round', 1, 'tls')`, tunnelB, nodeB).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel B: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelA); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelA).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel A: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelB); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelB).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel B: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'batch_switch_user', 'batch-switch-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelA, now, now)
|
||||
if err != nil {
|
||||
`, tunnelA, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, _ := forwardRes.LastInsertId()
|
||||
forwardID := mustLastInsertID(t, repo, "batch-switch-forward")
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 23001); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 23001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
@@ -429,19 +387,12 @@ func TestForwardBatchChangeTunnelRollbackOnSyncFailure(t *testing.T) {
|
||||
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||
}
|
||||
|
||||
var tunnelAfter int64
|
||||
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
|
||||
t.Fatalf("query forward tunnel_id: %v", err)
|
||||
}
|
||||
tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
|
||||
if tunnelAfter != tunnelA {
|
||||
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
|
||||
}
|
||||
|
||||
var nodeAfter int64
|
||||
var portAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
|
||||
t.Fatalf("query forward_port: %v", err)
|
||||
}
|
||||
nodeAfter, portAfter := mustQueryInt64Int(t, repo, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if nodeAfter != nodeA || portAfter != 23001 {
|
||||
t.Fatalf("expected forward_port rollback to node=%d port=23001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
|
||||
}
|
||||
@@ -457,21 +408,20 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(100, 'stable_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('stable-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, _ := tunnelRes.LastInsertId()
|
||||
tunnelID := mustLastInsertID(t, repo, "stable-tunnel")
|
||||
|
||||
// 1. Assign permission (creates new user_tunnel)
|
||||
// userTunnelBatchAssign expects structure: {userId: 123, tunnels: [{tunnelId: 456, ...}]}
|
||||
@@ -490,10 +440,7 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
|
||||
t.Fatalf("expected code 0, got %d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
var initialID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&initialID); err != nil {
|
||||
t.Fatalf("query initial user_tunnel id: %v", err)
|
||||
}
|
||||
initialID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID)
|
||||
|
||||
// 2. Re-assign permission (should UPDATE, not INSERT)
|
||||
reassignPayload := `{"userId":100,"tunnels":[{"tunnelId":` + jsonNumber(tunnelID) + `}]}`
|
||||
@@ -512,18 +459,12 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Verify stable ID and no duplicates
|
||||
var count int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&count); err != nil {
|
||||
t.Fatalf("query count: %v", err)
|
||||
}
|
||||
count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID)
|
||||
if count != 1 {
|
||||
t.Fatalf("expected exactly 1 user_tunnel record, got %d", count)
|
||||
}
|
||||
|
||||
var currentID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(¤tID); err != nil {
|
||||
t.Fatalf("query current user_tunnel: %v", err)
|
||||
}
|
||||
currentID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID)
|
||||
|
||||
if currentID != initialID {
|
||||
t.Fatalf("user_tunnel ID changed from %d to %d (unstable ID!)", initialID, currentID)
|
||||
|
||||
@@ -15,47 +15,35 @@ func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "group-contract-tunnel")
|
||||
|
||||
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID, err := ugRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read user_group id: %v", err)
|
||||
}
|
||||
userGroupID := mustLastInsertID(t, repo, "ug-contract")
|
||||
|
||||
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID, err := tgRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel_group id: %v", err)
|
||||
}
|
||||
tunnelGroupID := mustLastInsertID(t, repo, "tg-contract")
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_group_tunnel: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now); err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now).Error; err != nil {
|
||||
t.Fatalf("insert group_permission: %v", err)
|
||||
}
|
||||
|
||||
@@ -70,15 +58,9 @@ func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
router.ServeHTTP(bindRes, bindReq)
|
||||
assertCode(t, bindRes, 0)
|
||||
|
||||
var userTunnelID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||
t.Fatalf("query user_tunnel after bind: %v", err)
|
||||
}
|
||||
userTunnelID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID)
|
||||
|
||||
var grantCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after bind: %v", err)
|
||||
}
|
||||
grantCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after bind")
|
||||
}
|
||||
@@ -89,17 +71,12 @@ func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
router.ServeHTTP(unbindRes, unbindReq)
|
||||
assertCode(t, unbindRes, 0)
|
||||
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after unbind: %v", err)
|
||||
}
|
||||
grantCount = mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
|
||||
}
|
||||
|
||||
var userTunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||
t.Fatalf("query user_tunnel after unbind: %v", err)
|
||||
}
|
||||
userTunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID)
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
|
||||
}
|
||||
@@ -110,42 +87,30 @@ func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "group-remove-tunnel")
|
||||
|
||||
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID, err := ugRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read user_group id: %v", err)
|
||||
}
|
||||
userGroupID := mustLastInsertID(t, repo, "ug-remove-contract")
|
||||
|
||||
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID, err := tgRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel_group id: %v", err)
|
||||
}
|
||||
tunnelGroupID := mustLastInsertID(t, repo, "tg-remove-contract")
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
@@ -170,20 +135,11 @@ func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
|
||||
assertCode(t, assignPermissionRes, 0)
|
||||
|
||||
var permissionID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID).Scan(&permissionID); err != nil {
|
||||
t.Fatalf("query group_permission id: %v", err)
|
||||
}
|
||||
permissionID := mustQueryInt64(t, repo, `SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID)
|
||||
|
||||
var userTunnelID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||
t.Fatalf("query user_tunnel after assign: %v", err)
|
||||
}
|
||||
userTunnelID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID)
|
||||
|
||||
var grantCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after assign: %v", err)
|
||||
}
|
||||
grantCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after permission assign")
|
||||
}
|
||||
@@ -194,25 +150,17 @@ func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
router.ServeHTTP(removeRes, removeReq)
|
||||
assertCode(t, removeRes, 0)
|
||||
|
||||
var permissionCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID).Scan(&permissionCount); err != nil {
|
||||
t.Fatalf("query group_permission after remove: %v", err)
|
||||
}
|
||||
permissionCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID)
|
||||
if permissionCount != 0 {
|
||||
t.Fatalf("expected group_permission removed, got %d", permissionCount)
|
||||
}
|
||||
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after remove: %v", err)
|
||||
}
|
||||
grantCount = mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
|
||||
}
|
||||
|
||||
var userTunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||
t.Fatalf("query user_tunnel after permission remove: %v", err)
|
||||
}
|
||||
userTunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID)
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
|
||||
}
|
||||
|
||||
@@ -17,22 +17,20 @@ import (
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
_, err := repo.DB().Exec(`
|
||||
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
|
||||
`, "captcha_enabled", "true", time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
`, "captcha_enabled", "true", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("enable captcha: %v", err)
|
||||
}
|
||||
|
||||
@@ -84,7 +82,7 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
router, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
const tunnelFlowGB = int64(500)
|
||||
const tunnelInFlow = int64(123)
|
||||
@@ -92,17 +90,13 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
const tunnelExpTimeMs = int64(2727251700000)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1); err != nil {
|
||||
tunnelID := mustLastInsertID(t, r, "contract-tunnel")
|
||||
if err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
@@ -205,7 +199,7 @@ func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
|
||||
|
||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
@@ -217,11 +211,11 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
}
|
||||
|
||||
key := "backup_contract_key"
|
||||
if _, err := repo.DB().Exec(`
|
||||
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
|
||||
`, key, "v1", time.Now().UnixMilli()); err != nil {
|
||||
`, key, "v1", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed config for backup contract: %v", err)
|
||||
}
|
||||
|
||||
@@ -271,7 +265,7 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
cfg, err := repo.GetConfigByName(key)
|
||||
cfg, err := r.GetConfigByName(key)
|
||||
if err != nil {
|
||||
t.Fatalf("query imported config: %v", err)
|
||||
}
|
||||
@@ -302,7 +296,7 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
cfg, err := repo.GetConfigByName(key)
|
||||
cfg, err := r.GetConfigByName(key)
|
||||
if err != nil {
|
||||
t.Fatalf("query restored config: %v", err)
|
||||
}
|
||||
@@ -311,24 +305,160 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
|
||||
t.Run("backup export and import preserve forward ports", func(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("seed tunnel for nullable chain export: %v", err)
|
||||
`, "backup-forward-tunnel", 1.0, 1, "tls", 0, now, now, 1, "", 88).Error; err != nil {
|
||||
t.Fatalf("seed tunnel for forward backup: %v", err)
|
||||
}
|
||||
tunnelID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id for nullable chain export: %v", err)
|
||||
tunnelID := mustLastInsertID(t, r, "backup-forward-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "backup-forward", tunnelID, "127.0.0.1:9000", "fifo", 0, 0, now, now, 1, 88).Error; err != nil {
|
||||
t.Fatalf("seed forward for backup: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "backup-forward")
|
||||
|
||||
expected := map[int64]int{
|
||||
2001: 21001,
|
||||
2002: 21002,
|
||||
}
|
||||
for nodeID, port := range expected {
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("seed forward_port %d:%d: %v", nodeID, port, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["forwards"]}`))
|
||||
exportReq.Header.Set("Authorization", adminToken)
|
||||
exportReq.Header.Set("Content-Type", "application/json")
|
||||
exportResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(exportResp, exportReq)
|
||||
|
||||
if exportResp.Code != http.StatusOK {
|
||||
t.Fatalf("expected export status 200, got %d", exportResp.Code)
|
||||
}
|
||||
|
||||
exportBody, err := io.ReadAll(exportResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read forwards backup body: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(exportBody, &payload); err != nil {
|
||||
t.Fatalf("decode forwards backup payload: %v", err)
|
||||
}
|
||||
version, _ := payload["version"].(string)
|
||||
if strings.TrimSpace(version) == "" {
|
||||
t.Fatalf("expected backup payload version, body=%s", string(exportBody))
|
||||
}
|
||||
|
||||
forwardsRaw, ok := payload["forwards"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected forwards array in payload, body=%s", string(exportBody))
|
||||
}
|
||||
|
||||
foundForward := false
|
||||
foundPorts := map[int64]int{}
|
||||
for _, item := range forwardsRaw {
|
||||
forwardMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idValue, ok := forwardMap["id"].(float64)
|
||||
if !ok || int64(idValue) != forwardID {
|
||||
continue
|
||||
}
|
||||
foundForward = true
|
||||
|
||||
portsRaw, ok := forwardMap["forwardPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected forwardPorts for forward %d in payload", forwardID)
|
||||
}
|
||||
for _, p := range portsRaw {
|
||||
portMap, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nodeID, nodeOK := portMap["nodeId"].(float64)
|
||||
port, portOK := portMap["port"].(float64)
|
||||
if nodeOK && portOK {
|
||||
foundPorts[int64(nodeID)] = int(port)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !foundForward {
|
||||
t.Fatalf("expected forward %d in exported forwards payload", forwardID)
|
||||
}
|
||||
if len(foundPorts) != len(expected) {
|
||||
t.Fatalf("expected %d exported forward ports, got %d", len(expected), len(foundPorts))
|
||||
}
|
||||
for nodeID, port := range expected {
|
||||
if got, ok := foundPorts[nodeID]; !ok || got != port {
|
||||
t.Fatalf("expected exported forward port node=%d port=%d, got %v", nodeID, port, foundPorts)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {
|
||||
t.Fatalf("clear forward_port before import: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {
|
||||
t.Fatalf("seed wrong forward_port before import: %v", err)
|
||||
}
|
||||
|
||||
payload["types"] = []string{"forwards"}
|
||||
importBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal forwards import payload: %v", err)
|
||||
}
|
||||
|
||||
importReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(importBody))
|
||||
importReq.Header.Set("Authorization", adminToken)
|
||||
importReq.Header.Set("Content-Type", "application/json")
|
||||
importResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(importResp, importReq)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode forwards import response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected forwards import code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
after := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
|
||||
|
||||
if len(after) != len(expected) {
|
||||
t.Fatalf("expected %d forward ports after import, got %d (%v)", len(expected), len(after), after)
|
||||
}
|
||||
for nodeID, port := range expected {
|
||||
if got, ok := after[nodeID]; !ok || got != port {
|
||||
t.Fatalf("expected forward_port node=%d port=%d after import, got %v", nodeID, port, after)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1).Error; err != nil {
|
||||
t.Fatalf("seed tunnel for nullable chain export: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "legacy-null-chain")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelID, "1", 1, nil, nil, nil, nil); err != nil {
|
||||
`, tunnelID, "1", 1, nil, nil, nil, nil).Error; err != nil {
|
||||
t.Fatalf("seed nullable chain_tunnel row: %v", err)
|
||||
}
|
||||
|
||||
@@ -431,19 +561,19 @@ func exportBackupPayload(t *testing.T, router http.Handler, path, token string)
|
||||
return payload
|
||||
}
|
||||
|
||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
h := handler.New(r, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), r
|
||||
}
|
||||
|
||||
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
@@ -504,15 +634,15 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
t.Fatalf("seed legacy node row: %v", err)
|
||||
}
|
||||
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
nodes, err := repo.ListNodes()
|
||||
nodes, err := r.ListNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("list nodes after migration: %v", err)
|
||||
}
|
||||
@@ -520,7 +650,7 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
t.Fatalf("expected 1 node after migration, got %d", len(nodes))
|
||||
}
|
||||
|
||||
columns := readTableColumns(t, repo.DB(), "node")
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
|
||||
if !columns[required] {
|
||||
@@ -528,34 +658,28 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, repo.DB(), "tunnel")
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
if !tunnelColumns["inx"] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
|
||||
}
|
||||
}
|
||||
|
||||
func readTableColumns(t *testing.T, db *store.DB, table string) map[string]bool {
|
||||
func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
columnTypes, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect %s columns: %v", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var defaultValue sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
t.Fatalf("scan %s pragma row: %v", table, err)
|
||||
for _, col := range columnTypes {
|
||||
name := strings.TrimSpace(col.Name())
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate %s pragma rows: %v", table, err)
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPostgresNodeCreateRepairsMissingIDDefaultContract(t *testing.T) {
|
||||
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
|
||||
if baseDSN == "" {
|
||||
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres contract tests")
|
||||
}
|
||||
|
||||
schemaName := "contract_node_id_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
adminDB, err := sql.Open("pgx", baseDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres admin connection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`)
|
||||
_ = adminDB.Close()
|
||||
})
|
||||
|
||||
if _, err := adminDB.Exec(`CREATE SCHEMA "` + schemaName + `"`); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schemaName, err)
|
||||
}
|
||||
|
||||
testDSN, err := withSearchPath(baseDSN, schemaName)
|
||||
if err != nil {
|
||||
t.Fatalf("build schema dsn: %v", err)
|
||||
}
|
||||
|
||||
r, err := repo.OpenPostgres(testDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres repository: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`ALTER TABLE node ALTER COLUMN id DROP DEFAULT`).Error; err != nil {
|
||||
_ = r.Close()
|
||||
t.Fatalf("drop node.id default to simulate drift: %v", err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatalf("close repository before reopen: %v", err)
|
||||
}
|
||||
|
||||
r, err = repo.OpenPostgres(testDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen postgres repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
columnDefault := mustQueryNullString(t, r, `
|
||||
SELECT column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'id'
|
||||
LIMIT 1
|
||||
`)
|
||||
if !columnDefault.Valid || !strings.Contains(strings.ToLower(columnDefault.String), "nextval(") {
|
||||
t.Fatalf("expected node.id default to be nextval(...), got %q", columnDefault.String)
|
||||
}
|
||||
|
||||
jwtSecret := "postgres-contract-secret"
|
||||
router := httpserver.NewRouter(handler.New(r, jwtSecret), jwtSecret)
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, jwtSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"pg-repair-node","serverIp":"10.77.0.10"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/node/create", body)
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCode(t, resp, 0)
|
||||
|
||||
nodeID := mustQueryInt64(t, r, `SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "pg-repair-node")
|
||||
if nodeID <= 0 {
|
||||
t.Fatalf("expected positive node id, got %d", nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func withSearchPath(dsn, schema string) (string, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -26,18 +25,13 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("create-entry", "10.20.0.1", "30000-30010")
|
||||
@@ -63,18 +57,12 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
|
||||
t.Fatalf("expected node-related error, got %q", out.Msg)
|
||||
}
|
||||
|
||||
var tunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel").Scan(&tunnelCount); err != nil {
|
||||
t.Fatalf("count tunnel: %v", err)
|
||||
}
|
||||
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
|
||||
if tunnelCount != 0 {
|
||||
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
|
||||
}
|
||||
|
||||
var chainCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM chain_tunnel`).Scan(&chainCount); err != nil {
|
||||
t.Fatalf("count chain_tunnel: %v", err)
|
||||
}
|
||||
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
|
||||
if chainCount != 0 {
|
||||
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
|
||||
}
|
||||
@@ -91,35 +79,26 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("update-entry", "10.30.0.1", "40000-40010")
|
||||
chainID := insertNode("update-chain", "10.30.0.2", "41000-41010")
|
||||
exitID := insertNode("update-exit", "10.30.0.3", "42000-42010")
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "update-port-tunnel")
|
||||
|
||||
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-port-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[[{"nodeId":` + jsonInt(chainID) + `,"protocol":"tls","strategy":"round"}]],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
|
||||
@@ -130,26 +109,17 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var chainPort int
|
||||
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID).Scan(&chainPort); err != nil {
|
||||
t.Fatalf("query chain port: %v", err)
|
||||
}
|
||||
chainPort := mustQueryInt(t, repo, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID)
|
||||
if chainPort <= 0 {
|
||||
t.Fatalf("expected chain node port to be assigned, got %d", chainPort)
|
||||
}
|
||||
|
||||
var outPort int
|
||||
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID).Scan(&outPort); err != nil {
|
||||
t.Fatalf("query out port: %v", err)
|
||||
}
|
||||
outPort := mustQueryInt(t, repo, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID)
|
||||
if outPort <= 0 {
|
||||
t.Fatalf("expected out node port to be assigned, got %d", outPort)
|
||||
}
|
||||
|
||||
var entryStrategy sql.NullString
|
||||
if err := repo.DB().QueryRow(`SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID).Scan(&entryStrategy); err != nil {
|
||||
t.Fatalf("query entry strategy: %v", err)
|
||||
}
|
||||
entryStrategy := mustQueryNullString(t, repo, `SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID)
|
||||
if !entryStrategy.Valid || strings.TrimSpace(entryStrategy.String) == "" {
|
||||
t.Fatalf("expected entry strategy to be non-null and non-empty")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestTunnelCreateWithIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertDualStackNode("ip-pref-entry", "10.50.0.1", "2001:db8::1", "50000-50010")
|
||||
exitID := insertDualStackNode("ip-pref-exit", "10.50.0.2", "2001:db8::2", "51000-51010")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
preference string
|
||||
}{
|
||||
{"v4-preference", "v4"},
|
||||
{"v6-preference", "v6"},
|
||||
{"empty-preference", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{"name":"tunnel-` + tc.name + `","type":2,"flow":99999,"status":1,"ipPreference":"` + tc.preference + `","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
stored, err := tryQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "tunnel-"+tc.name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
t.Skipf("tunnel not created (nodes offline), skipping DB verification")
|
||||
}
|
||||
t.Fatalf("query ip_preference: %v", err)
|
||||
}
|
||||
if stored != tc.preference {
|
||||
t.Fatalf("expected ip_preference=%q in DB, got %q", tc.preference, stored)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertDualStackNode("upd-entry", "10.60.0.1", "2001:db8:1::1", "60000-60010")
|
||||
exitID := insertDualStackNode("upd-exit", "10.60.0.2", "2001:db8:1::2", "61000-61010")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "update-ip-pref-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0, "").Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "update-ip-pref-tunnel")
|
||||
|
||||
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-ip-pref-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"ipPreference":"v6","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE id = ?`, tunnelID)
|
||||
if stored != "v6" {
|
||||
t.Fatalf("expected ip_preference='v6' after update, got %q", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelListReturnsIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
err = repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "list-ip-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (msg=%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
tunnels, ok := out.Data.([]interface{})
|
||||
if !ok || len(tunnels) == 0 {
|
||||
t.Fatalf("expected non-empty tunnel list, got %v", out.Data)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, raw := range tunnels {
|
||||
tm, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tm["name"] == "list-ip-pref-tunnel" {
|
||||
found = true
|
||||
pref, _ := tm["ipPreference"].(string)
|
||||
if pref != "v6" {
|
||||
t.Fatalf("expected ipPreference='v6' in list response, got %q", pref)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("tunnel 'list-ip-pref-tunnel' not found in list response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceColumnDefaultContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "no-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel without ip_preference: %v", err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "no-pref-tunnel")
|
||||
if stored != "" {
|
||||
t.Fatalf("expected default ip_preference='', got %q", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceColumnMigrationContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
colCount := mustQueryInt(t, repo, `SELECT COUNT(*) FROM pragma_table_info('tunnel') WHERE name = 'ip_preference'`)
|
||||
if colCount != 1 {
|
||||
t.Fatalf("expected ip_preference column to exist in tunnel table, found %d", colCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceCoalesceNullSafety(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
`, "null-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Skipf("DB does not allow NULL ip_preference (NOT NULL constraint): %v", err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "null-pref-tunnel")
|
||||
if stored != "" {
|
||||
t.Fatalf("COALESCE should convert NULL to empty string, got %q", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDualStackNodeIPFieldsStoredContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "ds-verify-node", "ds-secret", "10.70.0.1", "10.70.0.1", "2001:db8:2::1", "70000-70010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert dual-stack node: %v", err)
|
||||
}
|
||||
|
||||
v4, v6 := mustQueryTwoNullStrings(t, repo, `SELECT server_ip_v4, server_ip_v6 FROM node WHERE name = ?`, "ds-verify-node")
|
||||
if !v4.Valid || v4.String != "10.70.0.1" {
|
||||
t.Fatalf("expected server_ip_v4='10.70.0.1', got %v", v4)
|
||||
}
|
||||
if !v6.Valid || v6.String != "2001:db8:2::1" {
|
||||
t.Fatalf("expected server_ip_v6='2001:db8:2::1', got %v", v6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceValidValuesContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
for _, pref := range []string{"", "v4", "v6"} {
|
||||
name := "valid-pref-" + pref
|
||||
if pref == "" {
|
||||
name = "valid-pref-empty"
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, 1.0, 2, "tls", 99999, now, now, 1, nil, 0, pref).Error; err != nil {
|
||||
t.Fatalf("insert tunnel with ip_preference=%q: %v", pref, err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, name)
|
||||
if stored != pref {
|
||||
t.Fatalf("expected ip_preference=%q, got %q for %s", pref, stored, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,48 +16,43 @@ func TestUserTunnelVisibleListContracts(t *testing.T) {
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
insertTunnel := func(name string, status int, inx int64) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx)
|
||||
if err != nil {
|
||||
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
enabledA := insertTunnel("enabled-A", 1, 1)
|
||||
enabledB := insertTunnel("enabled-B", 1, 2)
|
||||
disabledC := insertTunnel("disabled-C", 0, 3)
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0); err != nil {
|
||||
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel enabledA: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1); err != nil {
|
||||
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel enabledB: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1); err != nil {
|
||||
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel disabledC: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Generated:** Sun Feb 15 2026
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
@@ -27,6 +27,11 @@ go-gost/
|
||||
## CONVENTIONS
|
||||
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
|
||||
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
|
||||
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
|
||||
- All panel communication uses AES encryption with node `secret` as PSK.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -31,6 +31,8 @@ go-gost/x/
|
||||
## CONVENTIONS
|
||||
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
|
||||
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
|
||||
- Handlers/listeners/dialers follow consistent pattern: `{type}.go` + `metadata.go` per protocol.
|
||||
- OS-specific code uses `name_[os].go` suffix (e.g., `tun_linux.go`, `tun_darwin.go`).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
# GOST SOCKET KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
**Generated:** Sun Feb 15 2026
|
||||
|
||||
## OVERVIEW
|
||||
Socket utilities and wrappers for GOST forwarding.
|
||||
**Stack:** Go, GOST core.
|
||||
WebSocket reporter and socket utilities for panel integration.
|
||||
**Stack:** Go, GOST core, gorilla/websocket.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
socket/
|
||||
├── socket.go # Core socket interface
|
||||
├── udp.go # UDP socket handling
|
||||
├── packet.go # Packet framing
|
||||
├── packetconn.go # Packet connection wrapper
|
||||
└── ... # Additional socket utilities
|
||||
├── websocket_reporter.go # Agent-to-panel telemetry (1504 LOC)
|
||||
├── service.go # Socket service orchestration (534 LOC)
|
||||
├── socket.go # Core socket interface
|
||||
├── udp.go # UDP socket handling
|
||||
├── packet.go # Packet framing
|
||||
└── packetconn.go # Packet connection wrapper
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Panel Reporting** | `websocket_reporter.go` | Real-time system info (CPU, mem, uptime) every 2s |
|
||||
| **Command Handling** | `websocket_reporter.go` | Processes `AddService`, `UpgradeAgent`, etc. |
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent `go-gost/x/` conventions.
|
||||
- Low-level network primitives.
|
||||
- All panel communication is AES-encrypted using node `secret`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- DO NOT EDIT generated protobuf.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -0,0 +1,29 @@
|
||||
## Context
|
||||
|
||||
FLVX is a distributed system consisting of a central management panel (Backend + Frontend) and multiple forwarding agents (Nodes). The backend manages configuration, users, and billing, while agents handle the actual traffic forwarding using a modified GOST v3 stack. Communication between the panel and agents is secured and synchronized.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Document the high-level architecture of the system.
|
||||
- Describe the data model for users, tunnels, and nodes.
|
||||
- Explain the communication protocol between Panel and Agent.
|
||||
- Detail the authentication and authorization mechanisms.
|
||||
|
||||
**Non-Goals:**
|
||||
- Refactoring the existing architecture.
|
||||
- Detailed code-level documentation of every function.
|
||||
- Changing the database schema.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Architecture**: The system follows a client-server model where the Panel acts as the server and Agents act as clients that pull configuration and push status.
|
||||
- **Data Model**: Core entities are Users, Nodes (Agents), Tunnels (Groups of rules), and Forwarding Rules.
|
||||
- **Communication**: Agents use a heartbeat mechanism to report status and fetch configuration updates. The protocol uses AES encryption with a pre-shared key (Node Secret).
|
||||
- **Authentication**: JWT for Frontend-Backend communication; API Key (Node Secret) for Agent-Backend communication.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Security**: The security of the agent communication relies heavily on the secrecy of the Node Secret.
|
||||
- **Scalability**: Centralized management might become a bottleneck with a very large number of agents.
|
||||
- **Complexity**: Synchronizing state across distributed agents introduces complexity in handling failures and inconsistencies.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The current system lacks formal specification documents describing its capabilities. This makes it difficult for new developers to understand the intended behavior and for existing developers to ensure consistency when adding new features. Documenting the existing functionality will serve as a baseline for future changes and help in identifying gaps or inconsistencies.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Create formal specification documents for core system capabilities.
|
||||
- Document user management features (roles, limits).
|
||||
- Document tunnel and forwarding management (protocols, rules).
|
||||
- Document agent interactions and management.
|
||||
- Document system-level configurations.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `user-management`: Authentication, user roles, and resource limits.
|
||||
- `tunnel-management`: Creation and management of traffic tunnels (TCP/UDP).
|
||||
- `forwarding-rules`: Configuration of port forwarding and tunnel forwarding rules, including rate limiting.
|
||||
- `agent-management`: Management of forwarding agents, including installation and configuration synchronization.
|
||||
- `system-config`: Global system settings and configurations.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None, as this is a documentation effort for existing features. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Documentation**: New spec files in `openspec/specs/`.
|
||||
- **No Code Changes**: This change is purely documentation-focused.
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent Registration
|
||||
The system SHALL require new agents (Nodes) to register using a unique node key/secret.
|
||||
|
||||
#### Scenario: Node Connection
|
||||
- **WHEN** a new agent starts up with a valid configuration
|
||||
- **THEN** it connects to the backend and is registered as active.
|
||||
|
||||
### Requirement: Heartbeat Monitoring
|
||||
The system SHALL monitor the status of all registered agents using periodic heartbeats.
|
||||
|
||||
#### Scenario: Agent Status
|
||||
- **WHEN** an agent sends periodic heartbeats
|
||||
- **THEN** the system updates its last-seen timestamp and marks it as online.
|
||||
|
||||
### Requirement: Configuration Sync
|
||||
The system MUST synchronize configuration changes (tunnels, rules) to agents securely and reliably.
|
||||
|
||||
#### Scenario: Push Config
|
||||
- **WHEN** a configuration change is made in the panel
|
||||
- **THEN** the agent receives the updated configuration via the next heartbeat or push mechanism.
|
||||
|
||||
### Requirement: Version Management
|
||||
The system SHOULD track the version of the agent software running on each node.
|
||||
|
||||
#### Scenario: Version Reporting
|
||||
- **WHEN** an agent connects
|
||||
- **THEN** it reports its version number to the backend for tracking.
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Port Forwarding Rules
|
||||
The system SHALL support configuring port forwarding rules, defining the listening port on the node and the destination IP/port.
|
||||
|
||||
#### Scenario: Rule Configuration
|
||||
- **WHEN** an admin creates a port forwarding rule
|
||||
- **THEN** the rule is stored and synchronized to the assigned node.
|
||||
|
||||
### Requirement: Rate Limiting
|
||||
The system SHALL support configuring bandwidth rate limits for tunnels and users.
|
||||
|
||||
#### Scenario: Bandwidth Restriction
|
||||
- **WHEN** a rate limit is applied to a user
|
||||
- **THEN** their total bandwidth usage does not exceed the specified limit across all their tunnels.
|
||||
|
||||
### Requirement: Traffic Accounting
|
||||
The system MUST track incoming and outgoing traffic volume for each tunnel and user for billing and quota enforcement.
|
||||
|
||||
#### Scenario: Traffic Calculation
|
||||
- **WHEN** traffic flows through a tunnel
|
||||
- **THEN** the system increments the user's traffic usage counter accurately.
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Site Settings
|
||||
The system SHALL allow customization of the site title, logo, and other branding elements.
|
||||
|
||||
#### Scenario: Update Branding
|
||||
- **WHEN** an administrator changes the site logo
|
||||
- **THEN** the new logo is displayed across the interface.
|
||||
|
||||
### Requirement: Notification Settings
|
||||
The system SHALL support configuring notifications for user registration, traffic limits, and other events.
|
||||
|
||||
#### Scenario: User Limit Alert
|
||||
- **WHEN** a user approaches their traffic quota
|
||||
- **THEN** a notification is sent to the user/admin.
|
||||
|
||||
### Requirement: Backup & Restore
|
||||
The system SHOULD provide a mechanism to backup and restore database configurations.
|
||||
|
||||
#### Scenario: Restore Database
|
||||
- **WHEN** initiating a restore operation
|
||||
- **THEN** the system accepts a valid backup file and overwrites the current database state.
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tunnel Creation
|
||||
The system SHALL allow administrators to create tunnels, specifying protocols (TCP, UDP), listening ports, and destination endpoints.
|
||||
|
||||
#### Scenario: Create TCP Tunnel
|
||||
- **WHEN** an admin creates a new TCP tunnel configuration
|
||||
- **THEN** the backend stores the tunnel definition and assigns it to a node.
|
||||
|
||||
### Requirement: Tunnel Forwarding Configuration
|
||||
The system SHALL support both standard port forwarding (listening on a port and forwarding to a destination) and tunnel forwarding modes.
|
||||
|
||||
#### Scenario: Configure Port Forwarding
|
||||
- **WHEN** configuring a tunnel for port forwarding
|
||||
- **THEN** traffic arriving at the specified port is forwarded to the destination IP:port.
|
||||
|
||||
### Requirement: Tunnel Assignment
|
||||
The system SHALL allow tunnels to be assigned to specific users, tracking their usage against the user's quota.
|
||||
|
||||
#### Scenario: User Tunnel Usage
|
||||
- **WHEN** a user is assigned a tunnel
|
||||
- **THEN** traffic passing through that tunnel is accounted for under the user's usage.
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User Registration
|
||||
The system SHALL allow new users to register an account with a username and password.
|
||||
|
||||
#### Scenario: Successful Registration
|
||||
- **WHEN** a user submits valid registration details
|
||||
- **THEN** a new user account is created and the user can log in.
|
||||
|
||||
### Requirement: User Authentication
|
||||
The system MUST authenticate users using JWT tokens. The `Authorization` header MUST contain the raw token without a `Bearer` prefix.
|
||||
|
||||
#### Scenario: Valid Login
|
||||
- **WHEN** a user provides correct credentials
|
||||
- **THEN** the system returns a valid JWT token.
|
||||
|
||||
### Requirement: Role Management
|
||||
The system SHALL support different user roles, specifically Administrator and Regular User, with distinct permissions.
|
||||
|
||||
#### Scenario: Admin Access
|
||||
- **WHEN** an administrator logs in
|
||||
- **THEN** they have access to system-wide settings and all user management functions.
|
||||
|
||||
### Requirement: Resource Quotas
|
||||
The system SHALL allow administrators to set traffic limits and connection limits for individual users.
|
||||
|
||||
#### Scenario: Traffic Limit Enforcement
|
||||
- **WHEN** a user exceeds their traffic quota
|
||||
- **THEN** the system prevents further traffic forwarding for that user.
|
||||
@@ -0,0 +1,30 @@
|
||||
## 1. User Management Verification
|
||||
|
||||
- [ ] 1.1 Verify User Registration logic in backend
|
||||
- [ ] 1.2 Verify JWT Authentication implementation
|
||||
- [ ] 1.3 Verify Role Management checks
|
||||
- [ ] 1.4 Verify Quota Enforcement logic
|
||||
|
||||
## 2. Tunnel Management Verification
|
||||
|
||||
- [ ] 2.1 Verify Tunnel Creation API
|
||||
- [ ] 2.2 Verify Forwarding Configuration parsing
|
||||
- [ ] 2.3 Verify Tunnel Assignment logic
|
||||
|
||||
## 3. Forwarding Rules Verification
|
||||
|
||||
- [ ] 3.1 Verify Port Forwarding rule processing
|
||||
- [ ] 3.2 Verify Rate Limiting implementation (token bucket/leaky bucket?)
|
||||
- [ ] 3.3 Verify Traffic Accounting mechanisms
|
||||
|
||||
## 4. Agent Management Verification
|
||||
|
||||
- [ ] 4.1 Verify Agent Registration handshake
|
||||
- [ ] 4.2 Verify Heartbeat processing
|
||||
- [ ] 4.3 Verify Config Sync protocol
|
||||
|
||||
## 5. System Config Verification
|
||||
|
||||
- [ ] 5.1 Verify Site Settings API
|
||||
- [ ] 5.2 Verify Notification triggers
|
||||
- [ ] 5.3 Verify Backup/Restore functionality
|
||||
@@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -0,0 +1,52 @@
|
||||
# Project Overview
|
||||
|
||||
**Name**: FLVX (Flux Panel)
|
||||
**Description**: Traffic forwarding management system built on a forked GOST v3 stack. It provides a web-based panel for managing traffic tunnels, users, and forwarding rules.
|
||||
**Repository**: Monorepo containing Admin API, Web UI, and Forwarding Agent.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (`go-backend/`)
|
||||
- **Language**: Go
|
||||
- **Database**: SQLite (default), PostgreSQL (supported)
|
||||
- **Framework**: Standard library `net/http` (no heavy framework)
|
||||
- **ORM**: None (Raw SQL via `database/sql`)
|
||||
|
||||
### Frontend (`vite-frontend/`)
|
||||
- **Framework**: React
|
||||
- **Build Tool**: Vite (using `rolldown-vite` experimental bundler)
|
||||
- **UI Library**: HeroUI
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Mode**: Hybrid (Desktop + Mobile WebView support)
|
||||
|
||||
### Agent (`go-gost/`)
|
||||
- **Language**: Go
|
||||
- **Base**: Fork of `gost` v3
|
||||
- **Extensions**: Custom extensions in `go-gost/x/`
|
||||
|
||||
### Infrastructure
|
||||
- **Containerization**: Docker, Docker Compose (v4/v6)
|
||||
- **CI/CD**: GitHub Actions
|
||||
- **Installers**: Shell scripts (`panel_install.sh`, `install.sh`)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Panel**: Central management server (Go Backend + React Frontend).
|
||||
- **Agent**: Forwarding node running on remote servers.
|
||||
- **Communication**:
|
||||
- Frontend -> Backend: REST API (JWT Auth, raw token in header).
|
||||
- Agent -> Backend: AES-encrypted heartbeat/config sync.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Authentication**: `Authorization` header expects raw JWT token (do NOT add `Bearer ` prefix).
|
||||
- **API Response**: Standard envelope `{code, msg, data, ts}` (code 0 = success).
|
||||
- **Database**: Backend uses raw SQL queries. Do not introduce an ORM.
|
||||
- **File Structure**: Flat monorepo with language-prefixed directories (`go-backend`, `go-gost`).
|
||||
- **Protobuf**: Do not edit generated `.pb.go` files manually.
|
||||
|
||||
## Development
|
||||
|
||||
- **Backend Build**: `cd go-backend && make build`
|
||||
- **Frontend Dev**: `cd vite-frontend && npm run dev`
|
||||
- **Agent Run**: `cd go-gost && go run .`
|
||||
+14
-4
@@ -1,10 +1,10 @@
|
||||
# VITE FRONTEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Generated:** Sun Feb 15 2026
|
||||
|
||||
## OVERVIEW
|
||||
Web management console for FLVX (formerly Flux Panel).
|
||||
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
|
||||
Web management console for FLVX.
|
||||
**Stack:** React 18, Vite 5 (rolldown-vite), TypeScript, TailwindCSS 4, HeroUI.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -37,9 +37,19 @@ vite-frontend/
|
||||
|
||||
## CONVENTIONS
|
||||
- **Auth**: JWT stored as `localStorage.token`. Sent in `Authorization` header (no "Bearer" prefix).
|
||||
- **API**: Default base URL is `/api/v1/`.
|
||||
- **API**: Default base URL is `/api/v1/`. Responses follow `{code, msg, data, ts}` structure.
|
||||
- **WebView**: In WebView mode, base URL is derived from selected panel address. If unset, API returns `code: -1`.
|
||||
- **Routing**: URL query param `h5=true` forces mobile layout.
|
||||
- **Build**: `minify: false`, `treeshake: false` - unoptimized production bundles for debugging.
|
||||
- **ESLint**: `react-hooks/exhaustive-deps` disabled, unused vars starting with `_` ignored.
|
||||
- **Large Pages**: `forward.tsx` (3263 LOC), `tunnel.tsx` (2552 LOC), `node.tsx` (2194 LOC).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT ADD** tests - no test infrastructure (Vitest/Jest not configured).
|
||||
|
||||
## NOTES
|
||||
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- ESLint Flat Config format with custom import ordering rules.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
|
||||
@@ -285,3 +285,11 @@ export const exportBackup = async (types: string[] = []) => {
|
||||
|
||||
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
|
||||
Network.post("/backup/import", data);
|
||||
|
||||
export interface AnnouncementData {
|
||||
content: string;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export const getAnnouncement = () => Network.get<AnnouncementData>("/announcement/get");
|
||||
export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Textarea } from "@heroui/input";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
@@ -10,7 +11,7 @@ import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
||||
import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
@@ -144,6 +145,13 @@ export default function ConfigPage() {
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||
content: "",
|
||||
enabled: 0,
|
||||
});
|
||||
const [announcementLoading, setAnnouncementLoading] = useState(true);
|
||||
const [announcementSaving, setAnnouncementSaving] = useState(false);
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (!isAdmin()) {
|
||||
@@ -188,21 +196,51 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 延迟加载,避免阻塞初始渲染
|
||||
const timer = setTimeout(() => {
|
||||
loadConfigs(initialConfigs);
|
||||
loadAnnouncement();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 只在组件挂载时执行一次
|
||||
}, []);
|
||||
|
||||
const loadAnnouncement = async () => {
|
||||
setAnnouncementLoading(true);
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcement:", error);
|
||||
} finally {
|
||||
setAnnouncementLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAnnouncement = async () => {
|
||||
setAnnouncementSaving(true);
|
||||
try {
|
||||
const res = await updateAnnouncement(announcement);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success("公告保存成功");
|
||||
} else {
|
||||
toast.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("保存公告失败,请重试");
|
||||
} finally {
|
||||
setAnnouncementSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理配置项变更
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
const newConfigs = { ...configs, [key]: value };
|
||||
|
||||
setConfigs(newConfigs);
|
||||
|
||||
// 检查是否有变更
|
||||
const hasChangesNow =
|
||||
Object.keys(newConfigs).some(
|
||||
(k) => newConfigs[k] !== originalConfigs[k],
|
||||
@@ -479,7 +517,6 @@ export default function ConfigPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 操作提示 */}
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
@@ -493,6 +530,69 @@ export default function ConfigPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">公告管理</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
设置首页显示的公告内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-4 pt-6">
|
||||
{announcementLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Switch
|
||||
isSelected={announcement.enabled === 1}
|
||||
onValueChange={(checked) =>
|
||||
setAnnouncement({ ...announcement, enabled: checked ? 1 : 0 })
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{announcement.enabled === 1 ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</Switch>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
启用后,公告将在首页顶部显示
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label="公告内容"
|
||||
placeholder="请输入公告内容"
|
||||
value={announcement.content}
|
||||
variant="bordered"
|
||||
minRows={4}
|
||||
onChange={(e) =>
|
||||
setAnnouncement({ ...announcement, content: e.target.value })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={announcementSaving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={saveAnnouncement}
|
||||
>
|
||||
保存公告
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 备份与恢复 */}
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { getUserPackageInfo } from "@/api";
|
||||
import { getUserPackageInfo, getAnnouncement, type AnnouncementData } from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
flow: number;
|
||||
@@ -71,6 +71,7 @@ export default function DashboardPage() {
|
||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState("");
|
||||
@@ -170,22 +171,33 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态并加载数据,防止页面切换时显示旧数据
|
||||
setLoading(true);
|
||||
setUserInfo({} as UserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
|
||||
// 检查用户是否是管理员
|
||||
const adminStatus = localStorage.getItem("admin");
|
||||
|
||||
setIsAdmin(adminStatus === "true");
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcement:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -703,7 +715,35 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
{/* 响应式统计卡片 */}
|
||||
{announcement && announcement.content && (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
{announcement.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
|
||||
@@ -67,6 +67,7 @@ interface Tunnel {
|
||||
protocol?: string;
|
||||
flow: number; // 1: 单向, 2: 双向
|
||||
trafficRatio: number;
|
||||
ipPreference?: string;
|
||||
status: number;
|
||||
createdTime: string;
|
||||
}
|
||||
@@ -87,6 +88,7 @@ interface TunnelForm {
|
||||
flow: number;
|
||||
trafficRatio: number;
|
||||
inIp: string; // 入口IP
|
||||
ipPreference: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
@@ -141,6 +143,7 @@ export default function TunnelPage() {
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
@@ -253,8 +256,8 @@ export default function TunnelPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (form.trafficRatio < 0.0 || form.trafficRatio > 100.0) {
|
||||
newErrors.trafficRatio = "流量倍率必须在0.0-100.0之间";
|
||||
if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) {
|
||||
newErrors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||
}
|
||||
|
||||
// 隧道转发时的验证
|
||||
@@ -301,6 +304,7 @@ export default function TunnelPage() {
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
});
|
||||
setErrors({});
|
||||
@@ -313,21 +317,22 @@ export default function TunnelPage() {
|
||||
|
||||
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
|
||||
setForm({
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
type: tunnel.type,
|
||||
inNodeId: tunnel.inNodeId || [],
|
||||
outNodeId: tunnel.outNodeId || [],
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
status: tunnel.status,
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
type: tunnel.type,
|
||||
inNodeId: tunnel.inNodeId || [],
|
||||
outNodeId: tunnel.outNodeId || [],
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
@@ -1045,7 +1050,7 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
{/* 流量配置 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}>
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
||||
<div className="text-xs text-default-500">
|
||||
流量计算
|
||||
@@ -1062,6 +1067,16 @@ export default function TunnelPage() {
|
||||
{tunnel.trafficRatio}x
|
||||
</div>
|
||||
</div>
|
||||
{tunnel.type === 2 && tunnel.ipPreference && (
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
||||
<div className="text-xs text-default-500">
|
||||
连接偏好
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground mt-0.5">
|
||||
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1265,8 +1280,11 @@ export default function TunnelPage() {
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
placeholder="请输入流量倍率"
|
||||
placeholder="例如:0.5 或 1 或 2"
|
||||
type="number"
|
||||
step="any"
|
||||
min={0.01}
|
||||
max={100}
|
||||
value={form.trafficRatio.toString()}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
@@ -1293,6 +1311,27 @@ export default function TunnelPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{form.type === 2 && (
|
||||
<Select
|
||||
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
||||
label="隧道连接地址偏好"
|
||||
selectedKeys={[form.ipPreference || ""]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ipPreference: selectedKey || "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectItem key="">自动选择</SelectItem>
|
||||
<SelectItem key="v4">优先IPv4</SelectItem>
|
||||
<SelectItem key="v6">优先IPv6</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<h3 className="text-lg font-semibold">入口配置</h3>
|
||||
|
||||
|
||||
@@ -52,14 +52,17 @@ html, body {
|
||||
|
||||
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]),
|
||||
[data-slot="input-wrapper"][data-focus="true"]:not([data-invalid="true"]),
|
||||
[data-slot="input-wrapper"][data-focused="true"]:not([data-invalid="true"]) {
|
||||
[data-slot="input-wrapper"][data-focused="true"]:not([data-invalid="true"]),
|
||||
button[data-slot="trigger"][data-focus="true"]:not([data-invalid="true"]),
|
||||
button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
|
||||
border-color: var(--heroui-default-200, #e5e7eb) !important;
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[data-slot="input-wrapper"] input:focus {
|
||||
[data-slot="input-wrapper"] input:focus,
|
||||
[data-slot="input-wrapper"] textarea:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: transparent !important;
|
||||
|
||||
Reference in New Issue
Block a user