Compare commits

..

1 Commits

Author SHA1 Message Date
sagit 2848bf59bb Update contact link from group to channel 2026-05-07 09:26:07 +08:00
15 changed files with 47 additions and 2367 deletions
-178
View File
@@ -1,178 +0,0 @@
# Proxy Protocol 传输分析报告
**日期**: 2026-05-07
**测试环境**: 20.118.172.127 (Server 1) ↔ 108.181.90.137 (Server 2)
---
## 1. 代码流程分析
### 完整数据链路
```
前端 (proxyProtocol: 0|1|2)
→ 后端 handler mutations.go:1936
→ 数据库存储 forward.proxy_protocol (model.go:50)
→ 控制面 buildForwardServiceConfigs (control_plane.go:1791-1796)
→ handler metadata: {"proxyProtocol": 2}
→ Agent metadata 解析 (metadata.go:42)
→ handler.go:256 WrapClientConn()
→ conn.go:14 HeaderProxyFromAddrs(byte(ppv), src, dst)
→ conn.go:15 header.WriteTo(c)
→ 目标服务器收到 PROXY protocol header
```
### 关键代码
**写入 PROXY header** (`go-gost/x/internal/net/proxyproto/conn.go`):
```go
func WrapClientConn(ppv int, src, dst net.Addr, c net.Conn) net.Conn {
if ppv <= 0 {
return c
}
header := proxyproto.HeaderProxyFromrs(byte(ppv), src, dst)
header.WriteTo(c)
return c
}
```
**Handler 调用** (`go-gost/x/handler/forward/local/handler.go:256`):
```go
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), conn.LocalAddr(), cc)
```
- `src` = `conn.RemoteAddr()` → 客户端真实 IP ✅
- `dst` = `conn.LocalAddr()` → agent 监听地址 ✅
- `ppv` = 1 或 2 → 版本号正确 ✅
---
## 2. 实际传输测试结果
### 测试方法
1. 在 Server 2 启动 Python TCP 监听器,解析 PROXY protocol header
2. 在 Server 1 用当前代码编译 gost,配置 `proxyProtocol: 2` 转发到 Server 2
3. 通过 `nc` 发送测试数据,验证 Server 2 是否收到正确的 PROXY header
### 测试结果
| 版本 | 状态 | 接收到的 Header |
|------|------|----------------|
| **PPv2** | ✅ 成功 | `PP2 family=1 alen=12 SRC=127.0.0.1:45410 DST=127.0.0.1:20001` |
| **PPv1** | ✅ 成功 | `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001` |
### 测试详情
**PPv2 原始数据**:
```
Got 28 bytes
PP2 family=1 alen=12
SRC=127.0.0.1:45410 DST=127.0.0.1:20001
```
**PPv1 原始数据** (hex):
```
50524f58592054435034203132372e302e302e31203132372e302e302e312034333831362032303030310d0a
```
解码: `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001`
---
## 3. 单元测试结果
```
go-gost/x/handler/forward/local/ → TestLocalForwardHandlerSendsProxyProtocolToTarget ✅
go-backend/internal/http/handler/ → TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler ✅
go-backend/internal/store/repo/ → TestGetForwardRecordIncludesProxyProtocol ✅
```
全部通过 (3/3)。
---
## 4. 发现的问题
### 问题 1: `WriteTo` 错误未检查
**位置**: `go-gost/x/internal/net/proxyproto/conn.go:15`
```go
header.WriteTo(c) // 返回 (int64, error) 被忽略
```
**影响**: 如果写入失败(连接已断开、网络错误等),后续数据传输会在没有 PROXY header 的情况下继续,目标服务器可能解析出错。
**建议**:
```go
if _, err := header.WriteTo(c); err != nil {
return c // 或包装一个带错误的 conn
}
```
**严重程度**: 低(实际场景中,写入失败后 `Transport` 也会很快失败)
---
### 问题 2: 部署版本过旧
**服务器状态**:
| 服务器 | 组件 | 版本 | 状态 |
|--------|------|------|------|
| 20.118.172.127 | flux_agent | UPX 压缩,无法读取版本 | ✅ 运行中 |
| 20.118.172.127 | paneld | `/app/paneld` | ✅ 运行中 |
| 20.118.172.127 | /usr/local/bin/gost | v3.0.0 (go1.23.4) | 旧版,不支持 handler metadata 中的 proxyProtocol |
| 108.181.90.137 | flux_agent | 8.8MB | ✅ 运行中 |
**影响**: 旧版 gost 二进制不识别 handler metadata 中的 `proxyProtocol` 字段,PROXY protocol 功能在生产环境不可用。
**验证**: 用旧版 gost 测试时,目标服务器收到的原始数据为空,无 PROXY header。
---
### 问题 3: 数据库 Schema 缺失
**位置**: 20.118.172.127 的 `/app/data/gost.db`
**当前 forward 表 schema**:
```sql
CREATE TABLE `forward` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`user_id` integer NOT NULL,
`user_name` varchar(100) NOT NULL,
`name` varchar(100) NOT NULL,
`tunnel_id` integer NOT NULL,
`remote_addr` text NOT NULL,
`strategy` varchar(100) NOT NULL DEFAULT "fifo",
`in_flow` integer NOT NULL DEFAULT 0,
`out_flow` integer NOT NULL DEFAULT 0,
`created_time` integer NOT NULL,
`updated_time` integer NOT NULL,
`status` integer NOT NULL,
`inx` integer NOT NULL DEFAULT 0,
`speed_id` integer
);
```
**缺失字段**:
- `proxy_protocol` — PROXY protocol 版本
- `max_conn` — 最大连接数
- `ip_max_conn` — 每 IP 最大连接数
- `ip_speed_id` — 每 IP 限速 ID
**影响**: 后端无法存储和读取 proxy_protocol 配置,前端设置不会生效。
---
## 5. 结论
| 维度 | 状态 | 说明 |
|------|------|------|
| **代码实现** | ✅ 正确 | 完整的写入链路,版本/地址正确 |
| **单元测试** | ✅ 通过 | 3/3 测试覆盖 handler、repo、控制面 |
| **实际传输 (新编译版)** | ✅ 成功 | PPv1 和 PPv2 均正确传输 |
| **实际传输 (部署版)** | ❌ 不工作 | 旧版不支持 handler metadata 中的 proxyProtocol |
| **数据库 Schema** | ❌ 缺字段 | 需要迁移添加 proxy_protocol 等列 |
**总结**: 代码实现正确,PROXY protocol 传输逻辑无误。但生产服务器运行的是旧版本,需要升级 backend 和 agent 才能启用此功能。
File diff suppressed because it is too large Load Diff
@@ -1,295 +0,0 @@
# 面板本体一键升级设计
**日期**: 2026-05-04
**状态**: 待审核
**作者**: AI Assistant
## 概述
在 FLVX 管理面板中增加“面板升级”能力,使管理员可以在网页上检查 GitHub Release 并触发面板本体升级。目标是升级整套面板,而不是只升级转发节点或只替换后端二进制。
本设计采用 Docker Compose 整套升级方案:后端容器通过受限的 Docker socket 能力更新宿主机部署目录中的 `docker-compose.yml` 和 `.env`,然后启动独立的升级 helper 容器,由 helper 拉取新版 backend/frontend 镜像并重新启动 `backend` 与 `frontend` 服务。
## sub2api 参考结论
sub2api 的一键升级不是在宿主机执行 `docker compose pull/up`。它的运行形态是单体 Go 服务:前端构建产物 embed 到后端二进制,容器内只运行 `/app/sub2api`。升级接口下载 GitHub Release 中匹配当前系统和架构的 `sub2api_<version>_<os>_<arch>.tar.gz` 以及 `checksums.txt`,校验后把当前 `os.Executable()` 指向的 `/app/sub2api` 改名为 `/app/sub2api.backup`,再把新二进制原子替换到原路径。重启接口延迟调用 `os.Exit(0)`,依赖 Docker Compose 的 `restart: unless-stopped` 拉起同一个容器。
这种方式在 sub2api 的 Docker 部署中可行,是因为它的前后端在同一个二进制里。FLVX 当前是 `flux-panel-backend` 与 `vite-frontend` 两个容器,替换 `/app/paneld` 只能升级后端,不能升级前端页面。因此 FLVX 的“面板本体升级”需要更新 Compose 版本和两个镜像,而不是照搬二进制替换。
## 目标
1. 管理员可在面板上查看当前版本、最新版本、升级通道和升级能力状态。
2. 管理员可一键升级整套面板 backend/frontend。
3. 升级复用现有 GitHub Release 和 `FLUX_VERSION` 版本机制。
4. 升级复用现有 GitHub 加速配置 `github_proxy_enabled` / `github_proxy_url`。
5. 升级过程不接受任意命令、任意 URL 或任意 compose 路径。
6. 环境不满足时清晰提示不可用原因,不静默失败。
## 非目标
1. 不实现 sub2api 式后端二进制替换作为本次主路径。
2. 不支持从非本仓库 Release 下载升级资产。
3. 不支持普通用户触发升级。
4. 不支持在前端执行 shell 命令。
5. 不修改 `install.sh` 或 `panel_install.sh` 的本地安装菜单逻辑;发布流程仍可能覆盖这些脚本。
6. 不引入前端测试框架。
## 影响范围
### 后端
- `go-backend/internal/http/handler/handler.go`
- `go-backend/internal/http/handler/upgrade.go`
- 新增 `go-backend/internal/http/handler/system_upgrade.go`
- 新增 `go-backend/internal/http/handler/system_upgrade_test.go`
- `go-backend/Dockerfile`
### 部署模板
- `docker-compose-v4.yml`
- `docker-compose-v6.yml`
### 前端
- `vite-frontend/src/api/index.ts`
- `vite-frontend/src/api/types.ts`
- `vite-frontend/src/pages/config.tsx`
## 运行前提
升级能力仅在 Docker Compose 部署中可用,并要求后端容器具备以下条件:
1. 容器内存在 Docker CLI,且支持 `docker compose version`。
2. `/var/run/docker.sock` 挂载到后端容器。
3. 宿主部署目录挂载到容器内固定路径,例如 `/opt/flvx-panel`。
4. 环境变量 `PANEL_DEPLOY_DIR=/opt/flvx-panel`。
5. 环境变量 `PANEL_BACKEND_CONTAINER=flux-panel-backend`,为空时默认使用 `flux-panel-backend`;值必须匹配容器名安全字符集 `[A-Za-z0-9_.-]+`。
6. 部署目录内存在 `.env` 和 `docker-compose.yml`。
如果任一条件不满足,检查接口返回 `capable=false` 和明确的 `reason`,升级按钮禁用。
## 后端设计
### API
新增系统升级接口,路径使用 `/api/v1/system/*`,继续受现有 middleware 管控,仅管理员可访问。
| 方法 | 路径 | 用途 |
|------|------|------|
| `POST` | `/api/v1/system/version` | 返回当前版本、升级通道、能力状态和可选最新版本 |
| `POST` | `/api/v1/system/check-updates` | 强制查询 GitHub Release,返回最新版本和候选列表 |
| `POST` | `/api/v1/system/upgrade` | 执行升级 |
请求体:
```json
{
"channel": "stable",
"version": ""
}
```
`channel` 使用现有节点升级的通道语义:`stable` 匹配纯数字版本,`dev` 匹配 `alpha` / `beta` / `rc`。`version` 为空时自动选择该通道最新 Release。
`/api/v1/system/version` 返回:
```json
{
"currentVersion": "2.1.9-beta14",
"channel": "stable",
"latestVersion": "2.1.9",
"hasUpdate": true,
"capable": true,
"reason": "",
"deployDir": "/opt/flvx-panel",
"composeFile": "/opt/flvx-panel/docker-compose.yml",
"backendContainer": "flux-panel-backend"
}
```
`/api/v1/system/upgrade` 成功返回:
```json
{
"version": "2.1.9",
"message": "升级 helper 已启动,面板服务将短暂重启",
"commands": [
"docker run -d --rm --volumes-from flux-panel-backend ...",
"docker compose pull backend frontend",
"docker compose up -d backend frontend"
]
}
```
返回的 `commands` 只用于 UI 展示固定步骤,不包含用户输入或 shell 拼接结果。
### 版本来源
当前版本优先从容器环境变量读取:
1. `FLUX_VERSION`
2. `VITE_APP_VERSION` 不在后端容器中可靠存在,不作为后端版本来源。
3. 为空时返回 `dev`。
发布流程已经在 `panel_install.sh` 写入 `.env` 的 `FLUX_VERSION`,Compose 模板需要把该变量传给 backend 容器,保证后端可感知当前版本。
### Release 查询
复用现有 `fetchGitHubReleases`、`resolveLatestReleaseByChannel`、`normalizeReleaseChannel`、`releaseChannelFromTag`、`releaseChannelLabel` 和 GitHub 加速配置能力。新增函数只负责筛选系统升级所需资产:
- `docker-compose-v4.yml`
- `docker-compose-v6.yml`
是否下载 v4/v6 compose 文件通过当前部署目录中的 `docker-compose.yml` 判断:如果网络定义包含 `enable_ipv6: true`,选择 `docker-compose-v6.yml`;否则选择 `docker-compose-v4.yml`。
### 升级执行器
新增 `systemUpgradeExecutor`,职责明确分为可测试的小函数:
1. `checkSystemUpgradeCapability()` 检查 Docker CLI、Docker socket、部署目录、`.env`、`docker-compose.yml`。
2. `selectComposeAsset(currentCompose []byte) string` 选择 v4/v6 compose 资产。
3. `updateEnvVersion(path, version string) error` 原子更新 `.env` 中的 `FLUX_VERSION`。
4. `downloadCompose(version, assetName, dest string) error` 下载新版 compose 模板到临时文件。
5. `currentBackendImage(containerName string) (string, error)` 获取当前 backend 容器镜像 ID。
6. `startSystemUpgradeHelper(version string) error` 启动独立 helper 容器执行固定升级流程。
升级流程:
1. 获取全局升级锁,拒绝并发升级。
2. 校验目标版本存在且不是 draft。
3. 检查升级能力。
4. 备份 `.env` 为 `.env.upgrade.bak`,备份 `docker-compose.yml` 为 `docker-compose.yml.upgrade.bak`。
5. 下载目标版本的 compose 文件到部署目录临时文件。
6. 原子替换 `docker-compose.yml`。
7. 原子更新 `.env` 的 `FLUX_VERSION`。
8. 通过 Docker socket 查询当前 backend 容器的镜像 ID。
9. 使用当前 backend 镜像启动一个不属于 Compose 项目的临时 helper 容器。
10. helper 通过 `--volumes-from flux-panel-backend` 继承部署目录挂载,并显式挂载 `/var/run/docker.sock`。
11. helper 在 `PANEL_DEPLOY_DIR` 下执行 `docker compose pull backend frontend`。
12. helper 等待 5 秒,让 SQLite WAL 等文件刷盘。
13. helper 执行 `docker compose up -d backend frontend`,由 Compose 重建前端和后端。
14. 后端接口在 helper 成功启动后立即返回;浏览器随后会经历短暂断线。
PostgreSQL 模式不主动 pull 或重建 `postgres` 服务,避免无关数据库变动。新版 compose 文件仍保留 postgres 配置供后续手动迁移或重建使用。
### 命令安全
后端不暴露通用命令执行能力。后端只直接执行 Docker CLI 的固定参数,用于获取当前镜像和启动 helper:
```go
exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", backendContainer)
exec.CommandContext(ctx, "docker", "run", "-d", "--rm", "--name", helperName,
"--volumes-from", backendContainer,
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
"--entrypoint", "/bin/sh", imageID,
"-c", helperScript)
```
`helperScript` 由后端固定生成,不拼接用户输入:
```sh
cd "$PANEL_DEPLOY_DIR" && docker compose pull backend frontend && sleep 5 && docker compose up -d backend frontend
```
工作目录固定为 `PANEL_DEPLOY_DIR`。`PANEL_DEPLOY_DIR` 必须是绝对路径,且必须包含 `.env` 和 `docker-compose.yml`。接口输入只允许影响 `channel` 和已验证的 Release `version`。
### 超时和错误处理
1. Release 查询超时沿用现有 GitHub API 客户端超时。
2. 下载 compose 文件使用 60 秒超时。
3. 启动 helper 使用 30 秒超时,helper 内部命令不受原 HTTP 请求生命周期影响。
4. 任一步失败时返回错误信息,并尽量保留 `.upgrade.bak` 供人工恢复。
5. 如果 `.env` 更新后后续步骤失败,不自动回滚镜像或容器,避免误判导致更大破坏;错误信息提示备份文件位置。
## 部署模板设计
`docker-compose-v4.yml` 和 `docker-compose-v6.yml` 的 backend 服务增加:
```yaml
environment:
FLUX_VERSION: ${FLUX_VERSION:-dev}
PANEL_DEPLOY_DIR: /opt/flvx-panel
PANEL_BACKEND_CONTAINER: flux-panel-backend
volumes:
- sqlite_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ./:/opt/flvx-panel
```
`go-backend/Dockerfile` 的 runtime 镜像通过多阶段构建从官方 `docker:27-cli` 镜像复制 Docker CLI 和 compose 插件到 Debian runtime 镜像,避免依赖 Debian apt 源中的 Docker 包可用性:
```dockerfile
FROM docker:27-cli AS dockercli
FROM debian:bookworm-slim
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
```
实现时保留现有 Go builder 和 `/app/paneld` 入口,仅增加 Docker CLI stage 和复制步骤。
helper 容器使用当前 backend 容器的镜像 ID 启动,而不是额外依赖 `docker:cli` 镜像。这样不引入新的镜像仓库依赖,并保证 helper 内可用的 Docker CLI 与当前后端一致。
## 前端设计
在 `vite-frontend/src/pages/config.tsx` 的基本设置或数据库占用附近增加“面板升级”卡片,避免隐藏在节点页导致误解为“节点升级”。
展示内容:
1. 当前版本。
2. 最新版本。
3. 更新通道选择,复用现有 `stable` / `dev` 语义和 `UpdateReleaseChannel` 本地存储。
4. 升级能力状态:可用、不可用原因、Docker socket 高权限提示。
5. 操作按钮:检查更新、立即升级。
交互:
1. 页面加载时调用 `/system/version`。
2. 点击“检查更新”调用 `/system/check-updates`。
3. 点击“立即升级”前弹出确认框,明确提示服务会短暂中断,并提示 Docker socket 具备宿主高权限。
4. 升级请求只等待 helper 启动,超时设置为 60 秒。
5. 成功后 toast 显示“升级已触发,面板将在数十秒内重启”,并可提示用户稍后刷新。
## 安全边界
Docker socket 挂载等同于给后端容器宿主机级别控制能力。这是本设计的主要风险。缓解措施:
1. 仅 `/api/v1/system/*` 管理员接口可触发。
2. 不提供任意命令执行接口。
3. 不允许用户传入下载 URL。
4. 不允许用户传入 compose 路径。
5. 只升级本仓库 GitHub Release,且跳过 draft。
6. 前端明确展示 Docker socket 权限提示。
## 测试策略
### Go 单测
新增 `system_upgrade_test.go` 覆盖:
1. `selectComposeAsset` 对 v4/v6 compose 内容的判断。
2. `.env` 中已有 `FLUX_VERSION` 时更新值。
3. `.env` 中缺少 `FLUX_VERSION` 时追加值。
4. 缺少部署目录、`.env`、`docker-compose.yml`、Docker socket 时返回不可用原因。
5. helper 命令构造固定命令序列,不拼接用户输入。
6. 并发升级锁会拒绝第二个升级请求。
### 手动/集成验证
1. `go-backend`: `go test ./...`
2. `vite-frontend`: `pnpm run build`
3. 本地容器验证:启动 Compose 后检查设置页升级卡片可显示能力状态。
4. 在无 Docker socket 的开发环境验证按钮禁用并显示原因。
## 回滚与恢复
自动升级失败时不做自动容器回滚。后端会保留:
1. `.env.upgrade.bak`
2. `docker-compose.yml.upgrade.bak`
人工恢复步骤由错误信息提示:进入部署目录,按需恢复备份文件,再执行 `docker compose up -d backend frontend`。
## 决策记录
本设计已确定采用 Docker socket 整套升级方案,不再保留二进制替换作为本次实现路径。Docker socket 的权限风险通过管理员限制、命令白名单和前端提示控制。
@@ -180,41 +180,13 @@ func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
}
func (e *systemUpgradeExecutor) helperScript() string {
return `set -eu
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
cd "$PANEL_DEPLOY_DIR"
echo "" > "$LOGFILE"
log "开始面板升级"
log "工作目录: $(pwd)"
if [ ! -f docker-compose.yml ]; then
log "错误: docker-compose.yml 不存在"
exit 1
fi
if [ ! -f .env ]; then
log "错误: .env 不存在"
exit 1
fi
log "拉取新镜像..."
if ! docker compose pull backend frontend 2>&1 | tee -a "$LOGFILE"; then
log "错误: 拉取镜像失败"
exit 1
fi
log "等待旧容器释放资源..."
sleep 3
log "重启服务(force-recreate)..."
if ! docker compose up -d --force-recreate --remove-orphans backend frontend 2>&1 | tee -a "$LOGFILE"; then
log "错误: 重启服务失败"
exit 1
fi
log "升级完成"
`
return strings.Join([]string{
"set -eu",
`cd "$PANEL_DEPLOY_DIR"`,
"docker compose pull backend frontend",
"sleep 5",
"docker compose up -d backend frontend",
}, "\n")
}
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
+1 -7
View File
@@ -304,7 +304,6 @@ func autoMigrateAll(db *gorm.DB) error {
m := db.Migrator()
hasNode := m.HasTable(&model.Node{})
hasTunnel := m.HasTable(&model.Tunnel{})
hasForward := m.HasTable(&model.Forward{})
for _, item := range models {
if hasNode {
@@ -317,11 +316,6 @@ func autoMigrateAll(db *gorm.DB) error {
continue
}
}
if hasForward {
if _, ok := item.(*model.Forward); ok {
continue
}
}
if err := db.AutoMigrate(item); err != nil {
return err
}
@@ -402,7 +396,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
}
if m.HasTable(&model.Forward{}) {
for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} {
for _, field := range []string{"ProxyProtocol"} {
if m.HasColumn(&model.Forward{}, field) {
continue
}
@@ -117,68 +117,6 @@ func TestOpenBackfillsSQLiteLegacyTunnelProbeTargetColumns(t *testing.T) {
}
}
func TestOpenBackfillsSQLiteLegacyForwardColumns(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-forward.db")
db, err := gorm.Open(gsqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open legacy sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE forward (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_name VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
tunnel_id INTEGER NOT NULL,
remote_addr TEXT NOT NULL,
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
inx INTEGER NOT NULL DEFAULT 0,
speed_id INTEGER
)
`).Error; err != nil {
t.Fatalf("create legacy forward table: %v", err)
}
if err := db.Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, speed_id)
VALUES(1, 2, 'legacy-user', 'legacy-forward', 3, '127.0.0.1:9000', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
`).Error; err != nil {
t.Fatalf("insert legacy forward: %v", err)
}
if sqlDB, _ := db.DB(); sqlDB != nil {
_ = sqlDB.Close()
}
r, err := Open(dbPath)
if err != nil {
t.Fatalf("open migrated sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
m := r.DB().Migrator()
for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} {
if !m.HasColumn(&model.Forward{}, field) {
t.Fatalf("expected forward.%s column to exist", field)
}
}
var maxConn, ipMaxConn, proxyProtocol int
var ipSpeedID sql.NullInt64
if err := r.DB().Raw(`SELECT max_conn, ip_max_conn, ip_speed_id, proxy_protocol FROM forward WHERE id = 1`).Row().Scan(&maxConn, &ipMaxConn, &ipSpeedID, &proxyProtocol); err != nil {
t.Fatalf("query forward defaults: %v", err)
}
if maxConn != 0 || ipMaxConn != 0 || ipSpeedID.Valid || proxyProtocol != 0 {
t.Fatalf("expected default forward columns 0/0/NULL/0, got max_conn=%d ip_max_conn=%d ip_speed_id=%+v proxy_protocol=%d", maxConn, ipMaxConn, ipSpeedID, proxyProtocol)
}
}
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
+5 -19
View File
@@ -158,9 +158,6 @@ type WebSocketReporter struct {
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
http int
tls int
socks int
preferredWSScheme string
conn *websocket.Conn
curBackoff time.Duration // 当前重连退避间隔
@@ -299,9 +296,9 @@ func (w *WebSocketReporter) connect() error {
Socks int `json:"socks"`
}
cfg := LocalConfig{Http: w.http, Tls: w.tls, Socks: w.socks}
var cfg LocalConfig
if b, err := os.ReadFile("config.json"); err == nil {
_ = json.Unmarshal(b, &cfg)
json.Unmarshal(b, &cfg)
}
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
@@ -1372,7 +1369,7 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
// 执行重启脚本
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
script := buildAgentRestartScript(tmpPath, binaryPath)
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath)
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
if err := cmd.Start(); err != nil {
os.Remove(tmpPath)
@@ -1406,14 +1403,6 @@ func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
return nil
}
func buildAgentRestartScript(tmpPath, binaryPath string) string {
return fmt.Sprintf(
"sleep 1 && systemctl stop flux_agent && legacy_service='' && for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f \"$service_file\" ] && grep -Fq \"WorkingDirectory=/etc/gost\" \"$service_file\" && (grep -Fq \"ExecStart=/etc/gost/gost\" \"$service_file\" || (grep -Fq \"ExecStart=/usr/local/bin/gost\" \"$service_file\" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service=\"$service_file\"; break; fi; done && if [ -n \"$legacy_service\" ]; then (systemctl stop gost 2>/dev/null || true) && (systemctl disable gost 2>/dev/null || true) && rm -f /usr/local/bin/gost /etc/gost/gost \"$legacy_service\" && (systemctl daemon-reload 2>/dev/null || true); fi && mv %s %s && systemctl start flux_agent",
tmpPath,
binaryPath,
)
}
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
path := "config.json"
@@ -1661,16 +1650,13 @@ func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
fullURL := candidates[0]
fmt.Printf("🔗 WebSocket连接URL: %s\n", sanitizeWebSocketURL(fullURL))
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
reporter := NewWebSocketReporter(fullURL, secret)
// 保存 addr, secret, version 和协议能力供重连时使用
// 保存 addr, secret, version 供重连时使用
reporter.addr = addr
reporter.secret = secret
reporter.version = version
reporter.http = http
reporter.tls = tls
reporter.socks = socks
reporter.Start()
return reporter
}
-130
View File
@@ -1,45 +1,15 @@
package socket
import (
"bytes"
"errors"
"io"
"net/http"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("create stdout pipe: %v", err)
}
os.Stdout = w
defer func() {
os.Stdout = orig
_ = w.Close()
_ = r.Close()
}()
fn()
_ = w.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("read stdout: %v", err)
}
return buf.String()
}
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
@@ -163,103 +133,3 @@ func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
t.Fatalf("expected response body in message, got %s", msg)
}
}
func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) {
script := buildAgentRestartScript("/tmp/flux_agent.new", "/etc/flux_agent/flux_agent")
if !strings.Contains(script, "systemctl stop flux_agent") {
t.Fatalf("expected script to stop flux_agent, got %s", script)
}
if !strings.Contains(script, "mv /tmp/flux_agent.new /etc/flux_agent/flux_agent") {
t.Fatalf("expected script to replace the flux_agent binary, got %s", script)
}
if !strings.Contains(script, "systemctl stop gost") {
t.Fatalf("expected script to stop the legacy gost service, got %s", script)
}
if !strings.Contains(script, "systemctl disable gost") {
t.Fatalf("expected script to disable the legacy gost service, got %s", script)
}
if !strings.Contains(script, "rm -f /usr/local/bin/gost") {
t.Fatalf("expected script to remove the legacy gost binary, got %s", script)
}
if !strings.Contains(script, "WorkingDirectory=/etc/gost") {
t.Fatalf("expected script to scope cleanup to the legacy FLVX gost service definition, got %s", script)
}
if !strings.Contains(script, "systemctl start flux_agent") {
t.Fatalf("expected script to restart flux_agent, got %s", script)
}
if strings.Contains(script, "systemctl stop flux_agent && systemctl stop gost 2>/dev/null || true") {
t.Fatalf("expected legacy gost cleanup fallback to be scoped, got %s", script)
}
if runtime.GOARCH == "" {
t.Fatalf("unexpected empty runtime arch")
}
}
func TestStartWebSocketReporterWithConfigPreservesProtocolDefaultsWithoutConfigFile(t *testing.T) {
origDial := wsDial
defer func() { wsDial = origDial }()
origWD, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(origWD)
})
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("change working directory: %v", err)
}
urls := make(chan string, 1)
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
select {
case urls <- rawURL:
default:
}
return nil, nil, errors.New("dial failed")
}
reporter := StartWebSocketReporterWithConfig("panel.example.com:443", "abc", 1, 0, 1, "2.0.2")
defer reporter.Stop()
select {
case rawURL := <-urls:
if !strings.Contains(rawURL, "http=1&tls=0&socks=1") {
t.Fatalf("expected reconnect URL to preserve startup protocol values, got %s", rawURL)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for websocket dial")
}
}
func TestStartWebSocketReporterWithConfigLogsSanitizedURL(t *testing.T) {
origDial := wsDial
defer func() { wsDial = origDial }()
ready := make(chan struct{}, 1)
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
select {
case ready <- struct{}{}:
default:
}
return nil, nil, errors.New("dial failed")
}
output := captureStdout(t, func() {
reporter := StartWebSocketReporterWithConfig("panel.example.com:443", "abc123", 1, 0, 1, "2.0.2")
select {
case <-ready:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for websocket dial")
}
reporter.Stop()
})
if strings.Contains(output, "secret=abc123") {
t.Fatalf("expected logged websocket URL to mask the node secret, got %s", output)
}
if !strings.Contains(output, "secret=%2A%2A%2A") {
t.Fatalf("expected logged websocket URL to include masked secret, got %s", output)
}
}
+11 -79
View File
@@ -24,11 +24,6 @@ get_architecture() {
# 安装目录
INSTALL_DIR="/etc/flux_agent"
LEGACY_GOST_BINARY="/usr/local/bin/gost"
LEGACY_GOST_CONFIG_DIR="/etc/gost"
LEGACY_GOST_SERVICE_FILE_ETC="/etc/systemd/system/gost.service"
LEGACY_GOST_SERVICE_FILE_LIB="/lib/systemd/system/gost.service"
LEGACY_GOST_SERVICE_FILE_USR_LIB="/usr/lib/systemd/system/gost.service"
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
@@ -239,69 +234,6 @@ check_and_install_tcpkill() {
return 0
}
json_escape() {
local value="$1"
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//$'\n'/\\n}
value=${value//$'\r'/\\r}
value=${value//$'\t'/\\t}
printf '%s' "$value"
}
write_flux_agent_config() {
local path="$1"
printf '{\n "addr": "%s",\n "secret": "%s"\n}\n' \
"$(json_escape "$SERVER_ADDR")" \
"$(json_escape "$SECRET")" > "$path"
}
cleanup_legacy_gost_installation() {
local matched_service_files=()
local service_file=""
local removed_service_file="0"
for service_file in "$LEGACY_GOST_SERVICE_FILE_ETC" "$LEGACY_GOST_SERVICE_FILE_LIB" "$LEGACY_GOST_SERVICE_FILE_USR_LIB"; do
if [[ ! -f "$service_file" ]]; then
continue
fi
if ! grep -Fq "WorkingDirectory=$LEGACY_GOST_CONFIG_DIR" "$service_file"; then
continue
fi
if grep -Fq "ExecStart=$LEGACY_GOST_CONFIG_DIR/gost" "$service_file" || \
(grep -Fq "ExecStart=$LEGACY_GOST_BINARY" "$service_file" && [[ -f "$LEGACY_GOST_CONFIG_DIR/config.json" && -f "$LEGACY_GOST_CONFIG_DIR/gost.json" ]]); then
matched_service_files+=("$service_file")
fi
done
if [[ ${#matched_service_files[@]} -eq 0 ]]; then
return 0
fi
if systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
systemctl stop gost 2>/dev/null || true
systemctl disable gost 2>/dev/null || true
fi
for service_file in "${matched_service_files[@]}"; do
if [[ -f "$service_file" ]]; then
rm -f "$service_file"
removed_service_file="1"
fi
done
if [[ -f "$LEGACY_GOST_BINARY" ]]; then
rm -f "$LEGACY_GOST_BINARY"
fi
if [[ -f "$LEGACY_GOST_CONFIG_DIR/gost" ]]; then
rm -f "$LEGACY_GOST_CONFIG_DIR/gost"
fi
if [[ "$removed_service_file" == "1" ]]; then
systemctl daemon-reload 2>/dev/null || true
fi
}
# 获取用户输入的配置参数
get_config_params() {
@@ -347,8 +279,6 @@ install_flux_agent() {
mkdir -p "$INSTALL_DIR"
local tmp_binary="$INSTALL_DIR/flux_agent.new"
# 停止并禁用已有服务
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
echo "🔍 检测到已存在的flux_agent服务"
@@ -356,17 +286,16 @@ install_flux_agent() {
systemctl disable flux_agent 2>/dev/null && echo "🚫 禁用自启"
fi
# 删除旧文件
[[ -f "$INSTALL_DIR/flux_agent" ]] && echo "🧹 删除旧文件 flux_agent" && rm -f "$INSTALL_DIR/flux_agent"
# 下载 flux_agent
echo "⬇️ 下载 flux_agent 中..."
rm -f "$tmp_binary"
curl -L "$DOWNLOAD_URL" -o "$tmp_binary"
if [[ ! -f "$tmp_binary" || ! -s "$tmp_binary" ]]; then
rm -f "$tmp_binary"
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent"
if [[ ! -f "$INSTALL_DIR/flux_agent" || ! -s "$INSTALL_DIR/flux_agent" ]]; then
echo "❌ 下载失败,请检查网络或下载链接。"
exit 1
fi
cleanup_legacy_gost_installation
mv "$tmp_binary" "$INSTALL_DIR/flux_agent"
chmod +x "$INSTALL_DIR/flux_agent"
echo "✅ 下载完成"
@@ -376,7 +305,12 @@ install_flux_agent() {
# 写入 config.json (安装时总是创建新的)
CONFIG_FILE="$INSTALL_DIR/config.json"
echo "📄 创建新配置: config.json"
write_flux_agent_config "$CONFIG_FILE"
cat > "$CONFIG_FILE" <<EOF
{
"addr": "$SERVER_ADDR",
"secret": "$SECRET"
}
EOF
# 写入 gost.json
GOST_CONFIG="$INSTALL_DIR/gost.json"
@@ -446,13 +380,11 @@ update_flux_agent() {
# 先下载新版本
echo "⬇️ 下载最新版本..."
rm -f "$INSTALL_DIR/flux_agent.new"
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent.new"
if [[ ! -f "$INSTALL_DIR/flux_agent.new" || ! -s "$INSTALL_DIR/flux_agent.new" ]]; then
echo "❌ 下载失败。"
return 1
fi
cleanup_legacy_gost_installation
# 停止服务
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
-203
View File
@@ -98,7 +98,6 @@ EOF
chmod +x "$INSTALL_DIR/flux_agent"
local ask_called="0"
local cleanup_called="0"
ask_proxy_config() {
ask_called="1"
@@ -106,10 +105,6 @@ EOF
DOWNLOAD_URL=""
}
cleanup_legacy_gost_installation() {
cleanup_called="1"
}
check_and_install_tcpkill() { :; }
systemctl() {
@@ -138,7 +133,6 @@ EOF
update_flux_agent >/dev/null
assert_equals "1" "$ask_called" "update_flux_agent should ask for proxy config before downloading"
assert_equals "1" "$cleanup_called" "update_flux_agent should clean up legacy gost before restarting the agent"
assert_equals "$(build_download_url)" "$DOWNLOAD_URL" "update_flux_agent should honor the prompted proxy choice"
)
@@ -161,198 +155,6 @@ test_update_flux_agent_skips_proxy_prompt_when_not_installed() (
assert_equals "0" "$ask_called" "update_flux_agent should not prompt for proxy config when the agent is missing"
)
test_install_flux_agent_preserves_legacy_gost_when_download_fails() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -d)
cat > "$INSTALL_DIR/flux_agent" <<'EOF'
#!/bin/bash
echo "old version"
EOF
chmod +x "$INSTALL_DIR/flux_agent"
SERVER_ADDR="panel.example.com:443"
SECRET="secret"
DOWNLOAD_URL="https://example.com/gost"
local cleanup_called="0"
local rc="0"
ask_proxy_config() { :; }
ensure_download_url_initialized() { :; }
get_config_params() { :; }
check_and_install_tcpkill() { :; }
cleanup_legacy_gost_installation() {
cleanup_called="1"
}
systemctl() { return 0; }
curl() { return 0; }
( install_flux_agent >/dev/null ) || rc="$?"
assert_equals "1" "$rc" "install_flux_agent should fail when the download artifact is missing"
assert_equals "0" "$cleanup_called" "install_flux_agent should preserve legacy gost when download fails"
[[ -f "$INSTALL_DIR/flux_agent" ]] || fail "install_flux_agent should keep the existing flux_agent binary when download fails"
)
test_update_flux_agent_preserves_legacy_gost_when_download_fails() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -d)
cat > "$INSTALL_DIR/flux_agent" <<'EOF'
#!/bin/bash
echo "old version"
EOF
chmod +x "$INSTALL_DIR/flux_agent"
cat > "$INSTALL_DIR/flux_agent.new" <<'EOF'
#!/bin/bash
echo "stale version"
EOF
chmod +x "$INSTALL_DIR/flux_agent.new"
local cleanup_called="0"
local rc="0"
ask_proxy_config() {
PROXY_ENABLED="false"
DOWNLOAD_URL="https://example.com/gost"
}
check_and_install_tcpkill() { :; }
cleanup_legacy_gost_installation() {
cleanup_called="1"
}
systemctl() { return 0; }
curl() { return 0; }
update_flux_agent >/dev/null || rc="$?"
assert_equals "1" "$rc" "update_flux_agent should fail when the download artifact is missing"
assert_equals "0" "$cleanup_called" "update_flux_agent should preserve legacy gost when download fails"
[[ ! -f "$INSTALL_DIR/flux_agent.new" ]] || fail "update_flux_agent should remove stale download artifacts before retrying"
)
test_install_flux_agent_writes_json_safe_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -d)
SERVER_ADDR='panel"addr'
SECRET='sec\ret"1'
DOWNLOAD_URL="https://example.com/gost"
ask_proxy_config() { :; }
ensure_download_url_initialized() { :; }
get_config_params() { :; }
check_and_install_tcpkill() { :; }
cleanup_legacy_gost_installation() { :; }
systemctl() { return 0; }
curl() {
local output=""
while [[ $# -gt 0 ]]; do
if [[ "$1" == "-o" ]]; then
output="$2"
shift 2
continue
fi
shift
done
cat > "$output" <<'EOF'
#!/bin/bash
echo "new version"
EOF
chmod +x "$output"
}
( install_flux_agent >/dev/null 2>/dev/null ) || true
local actual
actual=$(<"$INSTALL_DIR/config.json")
local expected=$'{\n "addr": "panel\\"addr",\n "secret": "sec\\\\ret\\"1"\n}'
assert_equals "$expected" "$actual" "install_flux_agent should JSON-escape config values"
)
test_cleanup_legacy_gost_installation_removes_service_and_binary() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
LEGACY_GOST_BINARY=$(mktemp)
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
[Unit]
Description=Gost Proxy Service
[Service]
WorkingDirectory=$LEGACY_GOST_CONFIG_DIR
ExecStart=$LEGACY_GOST_CONFIG_DIR/gost
EOF
: > "$LEGACY_GOST_CONFIG_DIR/config.json"
: > "$LEGACY_GOST_CONFIG_DIR/gost.json"
local systemctl_calls=""
systemctl() {
systemctl_calls+=$'\n'"$*"
if [[ "$1" == "list-units" ]]; then
printf 'gost.service loaded active running\n'
fi
return 0
}
cleanup_legacy_gost_installation >/dev/null
if [[ -e "$LEGACY_GOST_BINARY" ]]; then
fail "cleanup_legacy_gost_installation should remove the legacy gost binary"
fi
if [[ -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]]; then
fail "cleanup_legacy_gost_installation should remove the legacy gost service file"
fi
[[ "$systemctl_calls" == *"stop gost"* ]] || fail "cleanup_legacy_gost_installation should stop the legacy gost service"
[[ "$systemctl_calls" == *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should disable the legacy gost service"
[[ "$systemctl_calls" == *"daemon-reload"* ]] || fail "cleanup_legacy_gost_installation should reload systemd after removing the legacy service"
)
test_cleanup_legacy_gost_installation_preserves_unrelated_gost() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
LEGACY_GOST_BINARY=$(mktemp)
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF'
[Unit]
Description=Unrelated Gost Service
[Service]
WorkingDirectory=/srv/custom-gost
ExecStart=/usr/local/bin/gost -C /srv/custom-gost/gost.yaml
EOF
local systemctl_calls=""
systemctl() {
systemctl_calls+=$'\n'"$*"
if [[ "$1" == "list-units" ]]; then
printf 'gost.service loaded active running\n'
fi
return 0
}
cleanup_legacy_gost_installation >/dev/null
[[ -e "$LEGACY_GOST_BINARY" ]] || fail "cleanup_legacy_gost_installation should preserve unrelated gost binaries"
[[ -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]] || fail "cleanup_legacy_gost_installation should preserve unrelated gost service files"
[[ "$systemctl_calls" != *"stop gost"* ]] || fail "cleanup_legacy_gost_installation should not stop unrelated gost services"
[[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services"
)
test_install_script_accepts_proxy_url_env_without_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
@@ -501,11 +303,6 @@ test_install_script_asks_for_proxy_config
test_install_script_recomputes_download_url_after_prompt
test_update_flux_agent_asks_for_proxy_config
test_update_flux_agent_skips_proxy_prompt_when_not_installed
test_install_flux_agent_preserves_legacy_gost_when_download_fails
test_update_flux_agent_preserves_legacy_gost_when_download_fails
test_install_flux_agent_writes_json_safe_config
test_cleanup_legacy_gost_installation_removes_service_and_binary
test_cleanup_legacy_gost_installation_preserves_unrelated_gost
test_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_can_disable_proxy
test_panel_install_script_recomputes_compose_urls_after_prompt
+2 -2
View File
@@ -1,10 +1,10 @@
# 多阶段构建 - 构建阶段
FROM node:22-alpine AS builder
FROM node:20.19.0 AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack prepare pnpm@10 --activate && corepack enable pnpm && pnpm install --frozen-lockfile
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
+2 -1
View File
@@ -508,7 +508,8 @@ export interface SystemUpgradeVersionApiData {
capability: SystemUpgradeCapabilityApiData;
}
export interface SystemUpgradeCheckApiData extends SystemUpgradeVersionApiData {
export interface SystemUpgradeCheckApiData
extends SystemUpgradeVersionApiData {
releases: SystemUpgradeReleaseApiItem[];
}
+16 -30
View File
@@ -1,10 +1,3 @@
import type {
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeReleaseApiItem,
SystemUpgradeVersionApiData,
} from "@/api/types";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
@@ -39,6 +32,12 @@ import {
runSystemUpgrade,
type AnnouncementData,
} from "@/api";
import type {
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeReleaseApiItem,
SystemUpgradeVersionApiData,
} from "@/api/types";
import { BackIcon, SettingsIcon } from "@/components/icons";
import { ThemeSettings } from "@/components/theme-settings";
import { isAdmin } from "@/utils/auth";
@@ -329,12 +328,6 @@ export default function ConfigPage() {
systemUpgradeReleasesMatchChannel &&
systemUpgradeReleases.length > 0,
);
const canTriggerSystemUpgrade = Boolean(
!systemUpgradeLoading &&
!systemUpgradeChecking &&
!systemUpgradeExecuting &&
systemUpgradeInfo?.capability.capable !== false,
);
const canOpenSystemUpgradeModal = Boolean(
systemUpgradeInfo?.capability.capable &&
systemUpgradeHasConfirmedUpdate &&
@@ -428,9 +421,7 @@ export default function ConfigPage() {
hasUpdate:
response.data.channel === channel ? response.data.hasUpdate : false,
latestVersion:
response.data.channel === channel
? response.data.latestVersion
: "",
response.data.channel === channel ? response.data.latestVersion : "",
});
} else {
setSystemUpgradeInfo(null);
@@ -521,9 +512,7 @@ export default function ConfigPage() {
: "未获取到可用版本",
);
return Boolean(
data.capability.capable && data.hasUpdate && data.releases?.length,
);
return Boolean(data.capability.capable && data.hasUpdate && data.releases?.length);
} else {
setSystemUpgradeReleases([]);
setSystemUpgradeCheckedChannel(null);
@@ -1579,8 +1568,7 @@ export default function ConfigPage() {
<div>
<p className="text-xs text-default-500">部署目录</p>
<p className="mt-1 break-all font-medium">
{systemUpgradeInfo?.capability.deployDir ||
"未获取到部署目录"}
{systemUpgradeInfo?.capability.deployDir || "未获取到部署目录"}
</p>
</div>
<div>
@@ -1637,9 +1625,7 @@ export default function ConfigPage() {
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| string
| undefined;
const selected = Array.from(keys)[0] as string | undefined;
setSystemUpgradeSelectedVersion(selected || "");
}}
@@ -1662,15 +1648,17 @@ export default function ConfigPage() {
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
<Button
isLoading={systemUpgradeChecking}
variant="flat"
isLoading={systemUpgradeChecking}
onPress={handleCheckSystemUpgrade}
>
检查更新
</Button>
<Button
color="primary"
isDisabled={!canTriggerSystemUpgrade}
isDisabled={
!canOpenSystemUpgradeModal
}
isLoading={systemUpgradeExecuting}
onPress={handleOpenSystemUpgradeModal}
>
@@ -1937,12 +1925,10 @@ export default function ConfigPage() {
<ModalBody>
<div className="space-y-3 text-sm text-default-700 dark:text-default-300">
<p>
升级过程需要访问 Docker
Socket,并会在短时间内中断当前面板服务。
升级过程需要访问 Docker Socket,并会在短时间内中断当前面板服务。
</p>
<p>
请确认已经允许面板管理容器与宿主机 Docker
交互,并且可以接受升级期间的临时不可用。
请确认已经允许面板管理容器与宿主机 Docker 交互,并且可以接受升级期间的临时不可用。
</p>
<div className="space-y-2 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-warning-800 dark:border-warning-900/40 dark:bg-warning-950/30 dark:text-warning-200">
<p className="text-xs font-medium">升级前请确认</p>
+2 -9
View File
@@ -204,7 +204,6 @@ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
const toSafeString = (value: unknown): string => {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
};
@@ -266,13 +265,11 @@ const normalizeBestExitState = (value: unknown): BestExitState | undefined => {
const bestExitOwnerRoleText = (role?: string) => {
if (role === "chain") return "中转";
return "入口";
};
const bestExitDetailTitle = (state?: BestExitState) => {
if (!state?.items?.length) return undefined;
return state.items
.map(
(item) =>
@@ -3111,9 +3108,7 @@ export default function TunnelPage() {
}}
>
<SelectItem key="1">单向计算(仅上传)</SelectItem>
<SelectItem key="2">
双向计算(上传+下载)
</SelectItem>
<SelectItem key="2">双向计算(上传+下载)</SelectItem>
</Select>
<Input
@@ -3176,9 +3171,7 @@ export default function TunnelPage() {
)}
<div>
<div className="text-sm font-medium">
质量检测目标
</div>
<div className="text-sm font-medium">质量检测目标</div>
<p className="text-xs text-default-500 mt-0.5">
用于实时隧道质量检测、诊断目标和 best
最优出口评分,留空使用 www.bing.com:443
@@ -401,7 +401,6 @@ export function Select<T>({
</div>
) : (
<select
aria-label={label ? undefined : ariaLabel}
className={cn(
"w-full rounded-md border border-input bg-background px-3 py-2 text-foreground shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]",
sizeClass(size),
@@ -410,6 +409,7 @@ export function Select<T>({
)}
disabled={isDisabled}
id={generatedId}
aria-label={label ? undefined : ariaLabel}
required={isRequired}
value={singleValue}
onChange={handleChange}