Compare commits

...

8 Commits

Author SHA1 Message Date
sagit 8339f52280 fix(postgres): encode relay tunnel chain roles as text (#579) 2026-11-11 18:01:04 +08:00
sagit 2e85234612 docs: add copyable agent installation prompt (#578) 2026-11-10 14:41:18 +08:00
sagit b26e9ee9e4 fix: align card handles and show live deployment progress (#577)
* fix: keep user and limit drag handles beside names

* fix: show live node and tunnel deployment progress in list view
2026-11-10 10:55:53 +08:00
sagit 577c189737 feat: show current owner names and persist user and limit order (#576)
Resolve current forwarding-rule owner names by user ID. Add administrator-managed persistent user and speed-limit ordering, backup compatibility, and conflict recovery. Repair PostgreSQL explicit-ID import sequences transactionally.

Closes #574
Closes #575
2026-11-10 09:13:05 +08:00
sagit 3209ff8a59 fix(sqlite): backfill tunnel relay authentication mode (#573)
* fix(sqlite): backfill tunnel relay authentication mode

* ci: cover SQLite upgrade migrations in backend checks
2026-11-09 20:46:52 +08:00
sagit 7e81e1c65c fix(panel): make self-upgrades durable and observable (#572)
Persist upgrade jobs and progress across backend restarts; preserve Compose identity and database mounts, isolate backups, and fail safely on uncertain recreation. Add frontend recovery handling and lifecycle/permission regressions.
2026-11-09 20:24:03 +08:00
sagit a784350eaf fix(monitor): unify realtime state and ignore stale requests (#571) 2026-11-09 18:21:18 +08:00
sagit 743bf6f278 fix(agent): verify upgrades and support OpenRC lifecycle (#570) 2026-11-09 08:59:25 +08:00
67 changed files with 7175 additions and 1546 deletions
+10 -2
View File
@@ -63,6 +63,14 @@ jobs:
- name: Build
run: go build -v ./...
- name: Test repository and SQLite upgrade migrations
run: go test ./internal/store/repo -count=1
- name: Test panel upgrade lifecycle and permissions
run: |
go test ./internal/panelupgrade -count=1
go test ./internal/http/handler ./tests/contract -run 'SystemUpgrade|SelectComposeAsset|DownloadReleaseAsset|UpgradeBackup|RestoreBackup' -count=1
backend-postgres-contract:
name: Go Backend PostgreSQL Contract
runs-on: ubuntu-latest
@@ -95,10 +103,10 @@ jobs:
- name: Download dependencies
run: go mod download
- name: Run PostgreSQL contract test
- name: Run PostgreSQL contract tests
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
run: go test ./tests/contract -run 'TestPostgres(NodeCreateRepairsMissingIDDefaultContract|NewRelayTunnelEmitsMatchingPrivateEndpointAuthentication)$' -count=1
agent:
name: Build Agent
+21
View File
@@ -25,6 +25,27 @@ Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27
## 部署流程
---
### 让 AI Agent 帮你安装(复制即用)
将下面整段提示词复制给能执行终端命令的 AI Agent(如 Codex、Claude Code)。Agent 已运行在目标 Linux 服务器上时可直接安装;远程安装时,提供 SSH 主机别名或连接方式,使用已有 SSH 密钥登录即可,无需把私钥贴进对话。
```text
请帮我在目标 Linux 服务器上安装 FLVX 流量转发面板,直接执行安装并完成验证。
项目仓库:https://github.com/Sagit-chu/flvx
安装文档:https://github.com/Sagit-chu/flvx/blob/main/doc/install.md
1. 确认目标服务器:如果当前终端就在目标 Linux 服务器上,直接使用当前环境;否则使用我提供的 SSH 连接。缺少连接信息时,只询问必要信息,确认目标后再执行安装。
2. 先阅读项目 README、安装文档及将要执行的安装脚本,检查系统、架构、root/sudo 权限、Docker、Compose、端口占用和已有 FLVX 容器、数据卷及部署文件。已有部署时先确认是更新还是新装,保留原配置和数据。
3. 新安装默认使用仓库标记为 latest 的 Release、Docker Compose、SQLite,部署目录 /opt/flvx-panel,前端端口 6366、后端端口 6365。我已指定的版本、目录或端口优先;端口冲突时先与我确认替代端口。
4. 缺少依赖时按该发行版的官方方式安装 Docker Engine 和 Compose 插件。进入部署目录,从项目 Releases 下载所选版本的 panel_install.sh,先检查下载成功和脚本内容,再执行。使用交互式终端操作菜单,选择“安装面板”并按上述配置输入;以脚本实际提示为准。
5. 默认直连 GitHub,可设置 PROXY_ENABLED=false;连接失败时说明原因,再与我确认是否使用代理。脚本可能修改 Docker IPv6 配置并重启 Docker;若会影响现有服务,先说明影响并确认。保留现有防火墙和 SSH 规则,仅按需放行实际使用的面板端口;云安全组无法操作时列出需要我放行的端口。
6. 安装后检查 Compose 服务状态、后端健康检查、前端 HTTP 页面及后端 /flow/test 的响应,并从实际访问端验证连通性。失败时查看相关日志并修复后重试;受网络或权限限制无法完成的检查要明确说明,不能直接宣称安装成功。
7. 最后给我实际安装版本、部署目录、访问地址、验证结果,以及在该目录查看状态、查看日志和重启服务的命令。说明默认管理员账号和密码均为 admin_user,提醒首次登录立即修改密码;不要输出 .env 中的 JWT_SECRET、数据库密码等密钥。
```
需要安装转发节点时,告诉 Agent 按[节点端部署文档](doc/install.md#二节点端部署)操作,并提供节点服务器、面板后端地址及从面板获取的节点接入密钥;安装完成后以服务正常运行且面板显示节点在线为准。
### Docker Compose部署
#### 快速部署(安装最新版)
面板端:
+8
View File
@@ -12,9 +12,17 @@ import (
"go-backend/internal/app"
"go-backend/internal/config"
"go-backend/internal/panelupgrade"
)
func main() {
if len(os.Args) == 3 && os.Args[1] == "panel-upgrade-helper" {
if panelupgrade.RunHelper(os.Args[2]) != nil {
log.Print("panel upgrade helper stopped; inspect durable job status")
os.Exit(1)
}
return
}
cfg := config.FromEnv()
if cfg.JWTSecret == "" {
log.Println("warning: JWT_SECRET is empty")
@@ -0,0 +1,45 @@
package handler
import (
"errors"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
"net/http"
)
func (h *Handler) userDisplayOrder(w http.ResponseWriter, r *http.Request) {
updateDisplayOrder(w, r, h.repo.UpdateUserDisplayOrder)
}
func (h *Handler) speedLimitDisplayOrder(w http.ResponseWriter, r *http.Request) {
updateDisplayOrder(w, r, h.repo.UpdateSpeedLimitDisplayOrder)
}
func updateDisplayOrder(w http.ResponseWriter, r *http.Request, save func([]int64, []int64) error) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.Err(400, "请求方法错误"))
return
}
var req struct {
IDs []int64 `json:"ids"`
ExpectedIDs []int64 `json:"expectedIds"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(400, "请求参数错误"))
return
}
if err := save(req.IDs, req.ExpectedIDs); err != nil {
code := 500
if errors.Is(err, repo.ErrInvalidDisplayOrder) {
code = 400
}
if errors.Is(err, repo.ErrDisplayOrderConflict) {
code = 409
}
msg := "排序保存失败,请刷新后重试"
if code != 500 {
msg = err.Error()
}
response.WriteJSON(w, response.Err(code, msg))
return
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -186,6 +186,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
mux.HandleFunc("/api/v1/system/upgrade/status", h.systemUpgradeStatus)
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
@@ -227,6 +228,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
mux.HandleFunc("/api/v1/tunnel/batch-redeploy/stream", h.tunnelBatchRedeployStream)
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
@@ -247,6 +249,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder)
mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder)
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
+23 -37
View File
@@ -2046,51 +2046,37 @@ func normalizeBatchFailureReason(reason string) string {
return trimmed
}
func (h *Handler) redeployTunnelConfiguration(tunnelID int64) error {
if _, err := h.getTunnelRecord(tunnelID); err != nil {
return err
}
if nftMode, entryNodeIDs, err := h.tunnelUsesNftables(tunnelID); err != nil {
return err
} else if nftMode {
if len(entryNodeIDs) == 0 {
return errors.New("nftables 转发缺少入口节点")
}
return h.reconcileNftablesNodeByRequest(entryNodeIDs[0])
}
return h.redeployTunnelAndForwards(tunnelID)
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
return
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
result := batchOperationResult{}
for _, tunnelID := range ids {
tunnel, tunnelErr := h.getTunnelRecord(tunnelID)
tunnelName := ""
if tunnelErr == nil && tunnel != nil {
tunnelName, _ = h.repo.GetTunnelName(tunnelID)
name, _ := h.repo.GetTunnelName(tunnelID)
if err := h.redeployTunnelConfiguration(tunnelID); err != nil {
result.FailCount++
result.Failures = appendBatchFailure(result.Failures, tunnelID, name, err)
} else {
result.SuccessCount++
}
if tunnelErr != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, tunnelErr)
continue
}
if nftMode, entryNodeIDs, modeErr := h.tunnelUsesNftables(tunnelID); modeErr != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, modeErr)
continue
} else if nftMode {
if len(entryNodeIDs) == 0 {
fail++
failures = appendBatchFailureReason(failures, tunnelID, tunnelName, "nftables 转发缺少入口节点")
continue
}
if reconcileErr := h.reconcileNftablesNodeByRequest(entryNodeIDs[0]); reconcileErr != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, reconcileErr)
continue
}
success++
continue
}
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, err)
continue
}
success++
}
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
@@ -15,6 +15,7 @@ import (
"time"
"go-backend/internal/http/response"
"go-backend/internal/panelupgrade"
)
const (
@@ -75,6 +76,7 @@ type systemUpgradeCheckData struct {
}
type systemUpgradeRunData struct {
JobID string `json:"jobId"`
Version string `json:"version"`
Channel string `json:"channel"`
ComposeAsset string `json:"composeAsset"`
@@ -118,17 +120,7 @@ func validateBackendContainerName(value string) error {
return nil
}
func validateUpgradeVersion(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("upgrade version is empty")
}
for _, r := range value {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("unsafe upgrade version: contains control character")
}
}
return nil
}
func validateUpgradeVersion(value string) error { return panelupgrade.ValidateVersion(value) }
func (e *systemUpgradeExecutor) composePath() string {
return filepath.Join(e.deployDir, "docker-compose.yml")
@@ -136,6 +128,8 @@ func (e *systemUpgradeExecutor) composePath() string {
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
reasons := make([]string, 0)
if !filepath.IsAbs(e.deployDir) {
reasons = append(reasons, "部署目录必须是绝对路径")
@@ -143,8 +137,8 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
if err := validateBackendContainerName(e.backendContainer); err != nil {
reasons = append(reasons, err.Error())
}
if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil {
reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out))))
if err := exec.CommandContext(ctx, "docker", "--version").Run(); err != nil {
reasons = append(reasons, "docker CLI不可用")
}
if info, err := os.Stat(dockerSocketPath); err != nil {
reasons = append(reasons, "docker socket不可用: "+err.Error())
@@ -161,10 +155,10 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
} else if info.IsDir() {
reasons = append(reasons, "部署.env不是文件")
}
if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil {
reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out))))
if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
reasons = append(reasons, "docker compose不可用")
}
if _, err := e.currentBackendImage(ctx); err != nil {
if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(ctx); err != nil {
reasons = append(reasons, err.Error())
}
@@ -183,144 +177,6 @@ func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
return "docker-compose-v4.yml"
}
func (e *systemUpgradeExecutor) helperScript() string {
return `set -eu
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
cd "$PANEL_DEPLOY_DIR"
echo "" > "$LOGFILE"
log "开始面板升级"
log "工作目录: $(pwd)"
if [ ! -f docker-compose.yml ]; then
log "错误: docker-compose.yml 不存在"
exit 1
fi
if [ ! -f .env ]; then
log "错误: .env 不存在"
exit 1
fi
log "拉取新镜像..."
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 拉取镜像失败"
exit 1
fi
log "等待旧容器释放资源..."
sleep 3
log "重启服务(force-recreate)..."
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 重启服务失败"
exit 1
fi
log "升级完成"
`
}
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
if err := validateBackendContainerName(e.backendContainer); err != nil {
return nil, err
}
return []string{
"run", "-d", "--rm", "--name", helperName,
"--volumes-from", e.backendContainer,
"-v", dockerSocketPath + ":" + dockerSocketPath,
"-e", panelDeployDirEnv + "=" + e.deployDir,
"--entrypoint", "/bin/sh", imageID,
"-c", e.helperScript(),
}, nil
}
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
if err := validateUpgradeVersion(version); err != nil {
return err
}
mode, err := fileModeOrDefault(envPath, 0o600)
if err != nil {
return err
}
data, err := os.ReadFile(envPath)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
replaced := false
for i, line := range lines {
if strings.HasPrefix(line, "FLUX_VERSION=") {
lines[i] = "FLUX_VERSION=" + version
replaced = true
}
}
if !replaced {
trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n")
if trimmed == "" {
trimmed = "FLUX_VERSION=" + version
} else {
trimmed += "\nFLUX_VERSION=" + version
}
return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode)
}
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
return writeFileWithMode(envPath, []byte(content), mode)
}
func (e *systemUpgradeExecutor) backupFile(path string) (string, error) {
mode, err := fileModeOrDefault(path, 0o600)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
backupPath := path + ".upgrade.bak"
if err := writeFileWithMode(backupPath, data, mode); err != nil {
return "", err
}
return backupPath, nil
}
func (e *systemUpgradeExecutor) restoreBackup(path string) error {
backupPath := path + ".upgrade.bak"
mode, err := fileModeOrDefault(backupPath, 0o600)
if err != nil {
return err
}
data, err := os.ReadFile(backupPath)
if err != nil {
return err
}
return writeFileWithMode(path, data, mode)
}
func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error {
var errs []string
for _, path := range paths {
if err := e.restoreBackup(path); err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
if len(bytes.TrimSpace(data)) == 0 {
return fmt.Errorf("compose asset is empty")
}
mode, err := fileModeOrDefault(path, 0o644)
if err != nil {
return err
}
return writeFileWithMode(path, data, mode)
}
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
@@ -383,55 +239,6 @@ func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (st
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fallback, nil
}
return 0, err
}
return info.Mode().Perm(), nil
}
func writeFileWithMode(path string, data []byte, mode os.FileMode) error {
if err := os.WriteFile(path, data, mode); err != nil {
return err
}
return os.Chmod(path, mode)
}
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
if err := validateBackendContainerName(e.backendContainer); err != nil {
return "", err
}
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput()
if err != nil {
return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out)))
}
imageID := strings.TrimSpace(string(out))
if imageID == "" {
return "", fmt.Errorf("backend image id is empty")
}
return imageID, nil
}
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) {
args, err := e.buildHelperRunArgs(imageID, helperName)
if err != nil {
return "", err
}
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out)))
}
containerID := strings.TrimSpace(string(out))
if containerID == "" {
containerID = helperName
}
return containerID, nil
}
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
url := h.buildSystemUpgradeDownloadURL(version, filename)
client := &http.Client{Timeout: 60 * time.Second}
@@ -485,16 +292,26 @@ func releasesForChannel(releases []githubRelease, channel string) []systemUpgrad
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(io.LimitReader(r.Body, 4097))
if err != nil {
return err
}
if len(body) > 4096 {
return fmt.Errorf("request too large")
}
if len(bytes.TrimSpace(body)) == 0 {
return nil
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
return decoder.Decode(req)
if err := decoder.Decode(req); err != nil {
return err
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return fmt.Errorf("unexpected trailing JSON")
}
return nil
}
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
@@ -595,63 +412,63 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
}
imageID, err := exec.currentBackendImage(r.Context())
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
if err := validateUpgradeVersion(version); err != nil {
response.WriteJSON(w, response.ErrDefault("版本号格式无效"))
return
}
composePath := exec.composePath()
envPath := exec.envPath()
composeData, err := os.ReadFile(composePath)
composeData, err := os.ReadFile(exec.composePath())
if err != nil {
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
response.WriteJSON(w, response.ErrDefault("读取部署配置失败"))
return
}
composeAsset := exec.selectComposeAsset(composeData)
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败"))
return
}
if _, err := exec.backupFile(composePath); err != nil {
response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error()))
return
}
if _, err := exec.backupFile(envPath); err != nil {
response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error()))
return
}
if err := exec.replaceCompose(composePath, newCompose); err != nil {
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
}
response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error()))
return
}
if err := exec.updateEnvVersion(envPath, version); err != nil {
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
}
response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error()))
return
}
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
helperContainer, err := exec.startHelper(r.Context(), imageID, helperName)
// A detached helper must not be canceled when the browser disconnects.
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
job, imageID, err := (panelupgrade.Manager{Dir: exec.deployDir, Backend: exec.backendContainer}).Start(ctx, version, newCompose)
if err != nil {
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
}
response.WriteJSON(w, response.Err(-2, err.Error()))
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.HelperContainer, BackendImageID: imageID, Message: systemUpgradeMessage}))
}
response.WriteJSON(w, response.OK(systemUpgradeRunData{
Version: version,
Channel: channel,
ComposeAsset: composeAsset,
HelperContainer: helperContainer,
BackendImageID: imageID,
Message: systemUpgradeMessage,
}))
func (h *Handler) systemUpgradeStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
JobID string `json:"jobId"`
}
defer r.Body.Close()
decoder := json.NewDecoder(io.LimitReader(r.Body, 4097))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
var extra any
if decoder.Decode(&extra) != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
e := newSystemUpgradeExecutor()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
job, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Status(ctx, req.JobID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法读取升级任务状态"))
return
}
response.WriteJSON(w, response.OK(struct {
CurrentVersion string `json:"currentVersion"`
Job *panelupgrade.Job `json:"job"`
}{currentPanelVersion(), job}))
}
@@ -8,7 +8,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -128,149 +127,12 @@ func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
}
}
func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
t.Fatalf("updateEnvVersion() error = %v", err)
}
data, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n"
if string(data) != want {
t.Fatalf("env content = %q, want %q", string(data), want)
}
}
func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
t.Fatalf("updateEnvVersion() error = %v", err)
}
data, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n"
if string(data) != want {
t.Fatalf("env content = %q, want %q", string(data), want)
}
}
func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) {
for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} {
t.Run(version, func(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
original := []byte("JWT_SECRET=test\n")
if err := os.WriteFile(envPath, original, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
if err := exec.updateEnvVersion(envPath, version); err == nil {
t.Fatal("expected unsafe version to fail validation")
}
data, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != string(original) {
t.Fatalf("env content changed to %q, want %q", string(data), string(original))
}
})
}
}
func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) {
for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} {
t.Run(version, func(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
if err := exec.updateEnvVersion(envPath, version); err != nil {
t.Fatalf("updateEnvVersion() error = %v", err)
}
})
}
}
func TestUpdateEnvVersionPreservesFileMode(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
t.Fatalf("updateEnvVersion() error = %v", err)
}
info, err := os.Stat(envPath)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("env mode = %o, want 0600", got)
}
}
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
t.Fatal("expected unsafe container name to fail validation")
}
}
func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
if err != nil {
t.Fatalf("buildHelperRunArgs() error = %v", err)
}
want := []string{
"run", "-d", "--rm", "--name", "flvx-upgrade-helper",
"--volumes-from", "flux-panel-backend",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
"--entrypoint", "/bin/sh", "sha256:abc",
"-c", exec.helperScript(),
}
if !reflect.DeepEqual(args, want) {
t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want)
}
}
func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"}
if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil {
t.Fatal("expected unsafe backend container name to fail validation")
}
}
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
@@ -350,64 +212,6 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
}
}
func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
backupPath, err := exec.backupFile(path)
if err != nil {
t.Fatalf("backupFile() error = %v", err)
}
if backupPath != path+".upgrade.bak" {
t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak")
}
if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := exec.restoreBackup(path); err != nil {
t.Fatalf("restoreBackup() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "original" {
t.Fatalf("restored content = %q, want original", string(data))
}
}
func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
if _, err := exec.backupFile(path); err != nil {
t.Fatalf("backupFile() error = %v", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if err := exec.restoreBackup(path); err != nil {
t.Fatalf("restoreBackup() error = %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("restored mode = %o, want 0600", got)
}
}
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
var payload systemUpgradeRequest
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"time"
"go-backend/internal/http/response"
)
type redeployProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Success int `json:"success"`
Failed int `json:"failed"`
}
type redeployItemResult struct {
ID int64 `json:"id"`
Name string `json:"name"`
Success bool `json:"success"`
Reason string `json:"reason,omitempty"`
}
// tunnelBatchRedeployStream reports completed tunnels, not estimated runtime.
// A disconnected caller may have an in-flight mutation whose result it did not
// receive. Finish that mutation, but never start another or automatically retry.
func (h *Handler) tunnelBatchRedeployStream(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
return
}
// idsFromBody sorts IDs; ignore duplicate selections so each tunnel runs once.
unique := ids[:0]
for _, id := range ids {
if len(unique) == 0 || unique[len(unique)-1] != id {
unique = append(unique, id)
}
}
ids = unique
if len(ids) == 0 {
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
return
}
_, err := prepareDiagnosisStreamResponse(w)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
encoder := json.NewEncoder(w)
controller := http.NewResponseController(w)
emit := func(kind string, data interface{}) bool {
if r.Context().Err() != nil {
return false
}
// The server's WriteTimeout is an absolute request deadline. Renew only
// this stream's bounded write window as progress is emitted, so a long
// batch is not cut off despite making steady progress. Recorders and
// other non-network writers may not support deadlines.
if err := controller.SetWriteDeadline(time.Now().Add(2 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
return false
}
event := diagnosisStreamEvent{Type: kind, Data: data, TS: time.Now().UnixMilli()}
if err := encoder.Encode(event); err != nil {
return false
}
// Buffered writes can succeed while the actual socket flush fails.
// Do not start a mutation after its processing event failed to flush.
return controller.Flush() == nil
}
progress := redeployProgress{Total: len(ids)}
result := batchOperationResult{}
if !emit("start", progress) {
return
}
for _, id := range ids {
if r.Context().Err() != nil {
return
}
name, _ := h.repo.GetTunnelName(id)
if !emit("processing", map[string]interface{}{"id": id, "name": name, "progress": progress}) {
return
}
if r.Context().Err() != nil {
return
}
item := redeployItemResult{ID: id, Name: name}
if err := h.redeployTunnelConfiguration(id); err != nil {
result.FailCount++
result.Failures = appendBatchFailure(result.Failures, id, name, err)
item.Reason = result.Failures[len(result.Failures)-1].Reason
progress.Failed++
} else {
result.SuccessCount++
item.Success = true
progress.Success++
}
progress.Completed++
if !emit("item", map[string]interface{}{"result": item, "progress": progress}) {
return
}
}
emit("done", map[string]interface{}{"progress": progress, "result": result})
}
@@ -0,0 +1,248 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"go-backend/internal/http/middleware"
runtimenft "go-backend/internal/runtime/nftables"
)
type blockingRedeployManager struct {
fakeNftablesManager
entered chan struct{}
release chan struct{}
once sync.Once
}
func (m *blockingRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
m.once.Do(func() { close(m.entered); <-m.release })
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
}
func TestTunnelRedeployStreamFlushesBeforeSlowMutationAndReportsMixedResults(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
h.nftablesManager = manager
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(manager.release) }) }
defer release()
server := httptest.NewServer(http.HandlerFunc(h.tunnelBatchRedeployStream))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Post(server.URL, "application/json", strings.NewReader(fmt.Sprintf(`{"ids":[%d,999999,%d]}`, id, id)))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
decoder := json.NewDecoder(response.Body)
read := func(want string) map[string]interface{} {
t.Helper()
var event struct {
Type string
Data map[string]interface{}
}
if err := decoder.Decode(&event); err != nil {
t.Fatal(err)
}
if event.Type != want {
t.Fatalf("expected %s, got %s", want, event.Type)
}
return event.Data
}
if start := read("start"); start["total"] != float64(2) {
t.Fatalf("duplicate IDs not deduplicated: %+v", start)
}
processing := read("processing")
if processing["name"] != "stream-tunnel" || processing["id"] != float64(id) {
t.Fatalf("wrong current tunnel: %+v", processing)
}
select {
case <-manager.entered:
case <-time.After(2 * time.Second):
t.Fatal("did not start mutation")
}
// Both events arrived over HTTP while the actual mutation is still blocked.
release()
item := read("item")
if item["result"].(map[string]interface{})["success"] != true || item["progress"].(map[string]interface{})["completed"] != float64(1) {
t.Fatalf("wrong success result: %+v", item)
}
read("processing")
item = read("item")
if item["result"].(map[string]interface{})["success"] != false || item["result"].(map[string]interface{})["reason"] == "" {
t.Fatalf("wrong failure result: %+v", item)
}
done := read("done")
progress := done["progress"].(map[string]interface{})
result := done["result"].(map[string]interface{})
if progress["completed"] != float64(2) || progress["success"] != float64(1) || progress["failed"] != float64(1) || result["successCount"] != float64(1) || result["failCount"] != float64(1) {
t.Fatalf("incorrect final result: %+v", done)
}
if manager.reconcileCount() != 1 {
t.Fatalf("expected one execution, got %d", manager.reconcileCount())
}
if err := decoder.Decode(new(interface{})); err != io.EOF {
t.Fatalf("expected EOF after done, got %v", err)
}
}
func TestTunnelRedeployStreamStopsAfterDisconnect(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
first := seedTunnelForNftables(t, h, "first", fixture.nodeID)
second := seedTunnelForNftables(t, h, "second", fixture.nodeID)
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
h.nftablesManager = manager
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(fmt.Sprintf(`{"ids":[%d,%d]}`, first, second))).WithContext(ctx)
res := httptest.NewRecorder()
finished := make(chan struct{})
go func() { defer close(finished); h.tunnelBatchRedeployStream(res, req) }()
select {
case <-manager.entered:
case <-time.After(2 * time.Second):
close(manager.release)
t.Fatal("mutation did not start")
}
cancel()
close(manager.release)
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatal("handler did not stop")
}
if manager.reconcileCount() != 1 {
t.Fatalf("started another mutation after disconnect: %d", manager.reconcileCount())
}
if strings.Contains(res.Body.String(), `"type":"done"`) {
t.Fatal("disconnected stream claimed completion")
}
}
type failedRedeployWriter struct{ *httptest.ResponseRecorder }
func (w failedRedeployWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func TestTunnelRedeployStreamDoesNotMutateWhenInitialWriteFails(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
h.tunnelBatchRedeployStream(failedRedeployWriter{httptest.NewRecorder()}, req)
if manager.reconcileCount() != 0 {
t.Fatal("mutation ran despite failed progress stream")
}
}
type delayedRedeployManager struct{ fakeNftablesManager }
func (m *delayedRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
time.Sleep(100 * time.Millisecond)
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
}
type shortRedeployDeadlineWriter struct {
http.ResponseWriter
deadlineCalls *atomic.Int32
}
func (w shortRedeployDeadlineWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w shortRedeployDeadlineWriter) SetWriteDeadline(deadline time.Time) error {
remaining := time.Until(deadline)
if remaining < time.Minute || remaining > 2*time.Minute {
return fmt.Errorf("expected bounded two-minute write window, got %v", remaining)
}
w.deadlineCalls.Add(1)
// Shorten the production idle window for a real, fast timeout regression.
return http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
}
func TestTunnelRedeployStreamRenewsDeadlineThroughRequestLog(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
ids := make([]int64, 3)
for i := range ids {
ids[i] = seedTunnelForNftables(t, h, fmt.Sprintf("slow-%d", i), fixture.nodeID)
}
manager := &delayedRedeployManager{}
h.nftablesManager = manager
var deadlineCalls atomic.Int32
logged := middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream))
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logged.ServeHTTP(shortRedeployDeadlineWriter{w, &deadlineCalls}, r)
}))
server.Config.WriteTimeout = 50 * time.Millisecond
server.Start()
defer server.Close()
body, _ := json.Marshal(map[string]interface{}{"ids": ids})
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Post(server.URL, "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("stream timed out across batch: %v (body %s)", err, payload)
}
if !strings.Contains(string(payload), `"type":"done"`) {
t.Fatalf("batch did not finish: %s", payload)
}
if manager.reconcileCount() != 3 {
t.Fatalf("wrong execution count: %d", manager.reconcileCount())
}
if deadlineCalls.Load() != 8 {
t.Fatalf("each of 8 events must renew its deadline, got %d", deadlineCalls.Load())
}
}
type flushFailureRedeployWriter struct {
*httptest.ResponseRecorder
flushes int
}
func (w *flushFailureRedeployWriter) FlushError() error {
w.flushes++
if w.flushes == 2 {
return io.ErrClosedPipe
}
w.ResponseRecorder.Flush()
return nil
}
func TestTunnelRedeployStreamStopsBeforeMutationOnProcessingFlushError(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
id := seedTunnelForNftables(t, h, "flush-error", fixture.nodeID)
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
writer := &flushFailureRedeployWriter{ResponseRecorder: httptest.NewRecorder()}
middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream)).ServeHTTP(writer, req)
if writer.flushes != 2 {
t.Fatalf("expected start and processing flush, got %d", writer.flushes)
}
if manager.reconcileCount() != 0 {
t.Fatal("mutation ran after failed processing flush")
}
}
+1 -1
View File
@@ -180,7 +180,7 @@ func requiresAdmin(path string) bool {
}
switch path {
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
return true
case "/api/v1/config/update", "/api/v1/config/update-single":
return true
@@ -14,6 +14,11 @@ type statusWriter struct {
status int
}
// Unwrap preserves ResponseController features such as per-response deadlines.
func (w *statusWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
@@ -27,10 +32,12 @@ func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return hj.Hijack()
}
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
func (w *statusWriter) FlushError() error {
return http.NewResponseController(w.ResponseWriter).Flush()
}
func (w *statusWriter) Flush() {
_ = w.FlushError()
}
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
@@ -0,0 +1,66 @@
# Panel upgrade safety and recovery
The helper is a subcommand in the **currently running image**, dispatched before
application/database initialization. This fix must first be installed through the
normal administrator deployment procedure on older releases lacking the helper.
It cannot retroactively repair an already-running legacy upgrade helper.
Supported deployments have one `docker-compose.yml`, the original Compose project
labels, and a writable host bind mount containing that file and `.env`. The
helper mounts that directory at its **host absolute path** and passes explicit
project, project-directory, env-file and compose-file options. Multiple Compose
files, mismatched mounts/resources, or changed database settings fail closed.
Jobs are under `<deployment>/.panel-upgrades/<jobId>/` (directory 0700, files
0600). `*.original` contains immutable per-job configuration snapshots, not a live
database backup. `events.log` contains safe phase messages only. Legacy
`.upgrade.bak` files and `upgrade.log` are never changed. Helper containers remain
for diagnosis; they are intentionally not automatically removed. Operators may
remove completed helpers after retaining the job's files.
The `latest` pointer plus filesystem lock prevents simultaneous jobs across API
processes and backend replacement. Failed validation/pull leaves live files
untouched. A failed configuration application attempts to restore both files
before any recreate. Once `restarting` is recorded, failures require recovery:
no automatic database rollback, image rollback, or container rollback is safe.
The helper has a 30 minute overall deadline, including a maximum five minute
post-recreate verification window. Docker client timeout during recreation can
leave daemon work in flight; it is always treated as uncertain, not retried.
## Operator recovery after an interrupted/uncertain job
There is intentionally no remotely callable “clear lock” endpoint. Do not delete
job directories or rewrite status JSON to force another attempt.
1. Work on the Docker host and identify the job from `latest`, `status.json` and
`plan.json`. Preserve the entire job directory, live Compose/env files, and
container inspection information in a private location. These files can
contain credentials. Never paste `.env` or raw Compose config into public logs.
2. Restore Docker daemon access if necessary. Inspect the exact helper container
named by the job. If it is running, let it finish or explicitly stop it as an
administrator. Verify it has stopped. If launch was uncertain, establish that
no matching helper exists and no Docker create/up operation remains in flight.
Do not clear the job while any of these facts are uncertain.
3. Inspect the original project's backend and frontend container IDs, images,
mounts and health. Check the actual database and application logs privately.
After any attempted recreation, assume migrations may already have occurred.
Preserve a current database snapshot before further manual deployment work.
Use the normal documented deployment procedure to bring both services to a
consistent, healthy state. Do not copy an old database over the current one or
blindly redeploy an older image. Configuration snapshots alone cannot undo a
schema migration.
4. Only after verifying no helper/daemon work remains and both services and their
database are consistent, archive the `latest` pointer under a unique filename
in `.panel-upgrades/` (for example `resolved-<jobId>.pointer`). Do this while
the API is stopped, or hold an exclusive OS flock on `.panel-upgrades/lock`,
so no other start can race this manual recovery. Keep the job directory and
archived pointer. This permits a new job without changing recovery evidence.
5. Restart the API if stopped and reload the browser settings page so it discovers
the current `latest` task instead of following the resolved task ID. No current
job is expected. A fresh start creates a new ID and new backups, never
overwriting old evidence.
A lost helper, unavailable daemon, or stale status is displayed as
`recovery_required` while retaining the active lock. This is intentionally a
conservative derived status, so a helper that is still completing work can report
its actual durable result without racing a status request.
+311
View File
@@ -0,0 +1,311 @@
package panelupgrade
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"time"
)
const helperTimeout = 30 * time.Minute
var verificationTimeout = 5 * time.Minute
type composeConfig struct {
FullServices map[string]map[string]any `json:"-"`
Services map[string]struct {
Image string `json:"image"`
ContainerName string `json:"container_name"`
Volumes []map[string]any `json:"volumes"`
Environment map[string]string `json:"environment"`
} `json:"services"`
Volumes map[string]any `json:"volumes"`
Networks map[string]any `json:"networks"`
}
func composeArgs(p Plan, dir string, args ...string) []string {
return append([]string{"compose", "--project-name", p.Project, "--project-directory", p.HostDir, "--env-file", filepath.Join(dir, ".env"), "-f", filepath.Join(dir, "docker-compose.yml")}, args...)
}
func config(ctx context.Context, p Plan, dir string) (composeConfig, error) {
var c composeConfig
b, e := docker(ctx, composeArgs(p, dir, "config", "--format", "json")...)
if e != nil {
return c, e
}
e = json.Unmarshal(b, &c)
if e == nil {
var raw struct {
Services map[string]map[string]any `json:"services"`
}
e = json.Unmarshal(b, &raw)
c.FullServices = raw.Services
}
return c, e
}
func validateConfig(old, next composeConfig, p Plan) error {
// Compare complete normalized services, not a projection that can silently drop
// host bindings, commands, security options, networks or dependency changes.
if old.FullServices == nil || next.FullServices == nil {
return errors.New("missing full Compose configuration")
}
raw, _ := json.Marshal(old.FullServices)
var expected map[string]map[string]any
if json.Unmarshal(raw, &expected) != nil {
return errors.New("invalid configuration")
}
for _, service := range []string{"backend", "frontend"} {
if expected[service] == nil || next.FullServices[service] == nil {
return errors.New("missing service")
}
expected[service]["image"] = next.FullServices[service]["image"]
}
if env, ok := expected["backend"]["environment"].(map[string]any); ok {
env["FLUX_VERSION"] = p.Job.TargetVersion
} else {
return errors.New("missing backend environment")
}
if !reflect.DeepEqual(expected, next.FullServices) {
return errors.New("unsupported service configuration change; manual upgrade required")
}
for service, image := range map[string]string{"backend": "ghcr.io/sagit-chu/flux-panel-backend:", "frontend": "ghcr.io/sagit-chu/vite-frontend:"} {
a, ok := old.Services[service]
b, present := next.Services[service]
if !ok || !present || b.Image != image+p.Job.TargetVersion || a.ContainerName != b.ContainerName || !reflect.DeepEqual(a.Volumes, b.Volumes) {
return errors.New("service image, container identity or persistent mounts do not match the supported deployment")
}
}
if next.Services["backend"].ContainerName != p.Backend || next.Services["backend"].Environment["FLUX_VERSION"] != p.Job.TargetVersion {
return errors.New("backend identity/version mismatch")
}
for service := range next.Services {
if service != "backend" && service != "frontend" && service != "postgres" {
return errors.New("unsupported service")
}
}
for key, value := range old.Services["backend"].Environment {
if key != "FLUX_VERSION" && next.Services["backend"].Environment[key] != value {
return errors.New("backend environment changed; manual upgrade required")
}
}
for key, value := range next.Services["backend"].Environment {
if key != "FLUX_VERSION" && old.Services["backend"].Environment[key] != value {
return errors.New("backend environment changed; manual upgrade required")
}
}
// Database configuration and shared resource identities are not upgrade targets.
if !reflect.DeepEqual(old.Services["postgres"], next.Services["postgres"]) || !reflect.DeepEqual(old.Volumes, next.Volumes) || !reflect.DeepEqual(old.Networks, next.Networks) {
return errors.New("database or shared resource configuration changed; manual upgrade required")
}
return nil
}
// validateLive proves the normalized source configuration still describes the running
// backend. In particular, Compose must not attach a fresh database volume by accident.
func validateLive(ctx context.Context, p Plan, c composeConfig) error {
live, err := inspect(ctx, p.Backend)
if err != nil {
return err
}
if live.Image != p.Image || !live.State.Running || live.Config.Labels["com.docker.compose.project"] != p.Project || live.Config.Labels["com.docker.compose.service"] != "backend" {
return errors.New("running backend changed")
}
if len(live.Mounts) != len(c.Services["backend"].Volumes) {
return errors.New("unexpected live mounts; manual upgrade required")
}
for _, v := range c.Services["backend"].Volumes {
typ, _ := v["type"].(string)
src, _ := v["source"].(string)
dst, _ := v["target"].(string)
if typ == "volume" {
spec, ok := c.Volumes[src].(map[string]any)
if !ok {
return errors.New("unknown volume")
}
src, _ = spec["name"].(string)
}
if src == "" || dst == "" || (typ != "volume" && typ != "bind") {
return errors.New("unsupported mount")
}
found := false
for _, mount := range live.Mounts {
actual := mount.Source
if typ == "volume" {
actual = mount.Name
}
if mount.Type == typ && mount.Destination == dst && actual == src && mount.RW != (v["read_only"] == true) {
found = true
}
}
if !found {
return errors.New("live persistent mount does not match Compose")
}
}
env := map[string]string{}
for _, v := range live.Config.Env {
parts := strings.SplitN(v, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
for _, key := range []string{"DB_TYPE", "DB_PATH", "DATABASE_URL", "PANEL_DEPLOY_DIR", "PANEL_BACKEND_CONTAINER"} {
if env[key] != c.Services["backend"].Environment[key] {
return errors.New("live database/deployment environment does not match Compose")
}
}
return nil
}
// RunHelper runs before app/database initialization and does not depend on the old backend process.
func RunHelper(planPath string) error {
b, e := os.ReadFile(planPath)
if e != nil {
return e
}
var p Plan
if e = json.Unmarshal(b, &p); e != nil {
return e
}
if !idPattern.MatchString(p.Job.ID) || ValidateVersion(p.Job.TargetVersion) != nil || !projectPattern.MatchString(p.Project) {
return errors.New("invalid plan")
}
m := Manager{Dir: p.HostDir, Backend: p.Backend}
dir := m.jobDir(p.Job.ID)
if filepath.Clean(planPath) != filepath.Join(dir, "plan.json") {
return errors.New("invalid plan location")
}
// Reject duplicate helper invocations without affecting the live job.
f, e := os.OpenFile(filepath.Join(dir, "helper.started"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return e
}
f.Close()
j, e := m.read(p.Job.ID)
if e != nil {
return e
}
if !j.Active {
return errors.New("job is not active")
}
ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
defer cancel()
fail := func(msg string) error {
if e := m.write(j, "failed", msg); e != nil {
return e
}
return errors.New(msg)
}
recovery := func(msg string) error {
m.write(j, "recovery_required", msg+";请管理员核对容器、数据库和任务备份后人工恢复,禁止自动回滚或重复升级")
return errors.New(msg)
}
if e = m.write(j, "preparing", "校验部署配置和持久化资源"); e != nil {
return e
}
old, e := config(ctx, p, p.HostDir)
if e != nil {
return fail("现有 Compose 配置校验失败,线上配置未变更")
}
next, e := config(ctx, p, dir)
if e != nil {
return fail("目标 Compose 配置校验失败,线上配置未变更")
}
if validateConfig(old, next, p) != nil {
return fail("目标镜像、服务或持久化资源不匹配,需手动升级")
}
if validateLive(ctx, p, old) != nil {
return fail("运行中后端与部署配置不一致,需手动升级")
}
if e = m.write(j, "pulling", "正在拉取并检查目标镜像,线上配置尚未变更"); e != nil {
return e
}
if _, e = docker(ctx, composeArgs(p, dir, "pull", "backend", "frontend")...); e != nil {
return fail("镜像拉取失败或超时,线上配置未变更")
}
targets := map[string]string{}
for _, service := range []string{"backend", "frontend"} {
out, err := docker(ctx, "image", "inspect", "--format", "{{.Id}}", next.Services[service].Image)
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "sha256:") {
return fail("目标镜像校验失败,线上配置未变更")
}
targets[service] = strings.TrimSpace(string(out))
}
if validateLive(ctx, p, old) != nil {
return fail("拉取期间运行中后端发生变化,已安全取消")
}
// Detect edits since snapshots before replacing either live file.
for _, name := range []string{"docker-compose.yml", ".env"} {
live, err := os.ReadFile(filepath.Join(p.HostDir, name))
if err != nil {
return fail("读取线上配置失败")
}
backup, err := os.ReadFile(filepath.Join(dir, name+".original"))
if err != nil || string(live) != string(backup) {
return fail("线上配置已被修改,本任务已安全取消")
}
}
if e = m.write(j, "applying", "正在应用已校验配置"); e != nil {
return e
}
for _, name := range []string{"docker-compose.yml", ".env"} {
data, err := os.ReadFile(filepath.Join(dir, name))
if err == nil {
err = atomic(filepath.Join(p.HostDir, name), data)
}
if err != nil {
for _, restore := range []string{"docker-compose.yml", ".env"} {
original, re := os.ReadFile(filepath.Join(dir, restore+".original"))
if re != nil || atomic(filepath.Join(p.HostDir, restore), original) != nil {
return recovery("配置恢复失败,尚未执行容器重建")
}
}
return fail("应用配置失败,已恢复原配置,未执行容器重建")
}
}
// Persist the point of no automatic rollback BEFORE Docker can recreate any service.
if e = m.write(j, "restarting", "正在重建面板服务"); e != nil {
return recovery("无法记录重建状态,已停止执行")
}
if _, e = docker(ctx, composeArgs(p, p.HostDir, "up", "-d", "--no-deps", "--force-recreate", "--pull", "never", "backend", "frontend")...); e != nil {
return recovery("容器重建失败或超时,可能已部分生效")
}
if e = m.write(j, "verifying", "正在验证服务版本及健康状态"); e != nil {
return e
}
deadline := time.Now().Add(verificationTimeout)
for time.Now().Before(deadline) && ctx.Err() == nil {
ready := true
for _, service := range []string{"backend", "frontend"} {
c, err := inspect(ctx, next.Services[service].ContainerName)
if err != nil || !c.State.Running || c.Image != targets[service] || c.Config.Labels["com.docker.compose.project"] != p.Project || c.Config.Labels["com.docker.compose.service"] != service {
ready = false
continue
}
if c.State.Health != nil && c.State.Health.Status != "healthy" {
ready = false
}
if service == "backend" && c.State.Health == nil {
ready = false
}
}
if ready { // Frontend has no Docker healthcheck in the stock template.
probeCtx, probeCancel := context.WithTimeout(ctx, 10*time.Second)
_, probeErr := docker(probeCtx, "exec", next.Services["frontend"].ContainerName, "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:80/")
probeCancel()
ready = probeErr == nil
}
if ready {
return m.write(j, "succeeded", "升级完成,目标镜像和服务健康状态已验证")
}
select {
case <-ctx.Done():
case <-time.After(3 * time.Second):
}
}
return recovery("升级后服务健康检查失败或超时")
}
+310
View File
@@ -0,0 +1,310 @@
// Package panelupgrade implements durable, conservative Docker Compose upgrades.
package panelupgrade
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
)
var versionPattern = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?$`)
var idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
var projectPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
func ValidateVersion(v string) error {
if !versionPattern.MatchString(v) || len(v) > 128 {
return errors.New("invalid release version")
}
return nil
}
type Job struct {
ID string `json:"id"`
TargetVersion string `json:"targetVersion"`
Stage string `json:"stage"`
Active bool `json:"active"`
RequiresRecovery bool `json:"requiresRecovery"`
Message string `json:"message"`
StartedAt int64 `json:"startedAt"`
UpdatedAt int64 `json:"updatedAt"`
HelperContainer string `json:"helperContainer,omitempty"`
BackupDir string `json:"backupDir,omitempty"`
}
type Plan struct {
Job Job
DeployDir, HostDir, Project, Backend, Image string
}
type Manager struct{ Dir, Backend string }
func (m Manager) root() string { return filepath.Join(m.Dir, ".panel-upgrades") }
func (m Manager) jobDir(id string) string { return filepath.Join(m.root(), id) }
func atomic(path string, b []byte) error {
f, e := os.CreateTemp(filepath.Dir(path), ".write-")
if e != nil {
return e
}
n := f.Name()
defer os.Remove(n)
if e = f.Chmod(0600); e == nil {
_, e = f.Write(b)
}
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e != nil {
return e
}
if e = os.Rename(n, path); e != nil {
return e
}
d, e := os.Open(filepath.Dir(path))
if e != nil {
return e
}
defer d.Close()
return d.Sync()
}
func save(path string, v any) error {
b, e := json.Marshal(v)
if e != nil {
return e
}
return atomic(path, b)
}
func (m Manager) lock() (func(), error) {
if e := os.MkdirAll(m.root(), 0700); e != nil {
return nil, e
}
f, e := os.OpenFile(filepath.Join(m.root(), "lock"), os.O_CREATE|os.O_RDWR, 0600)
if e != nil {
return nil, e
}
if e = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
f.Close()
return nil, errors.New("another upgrade operation is in progress")
}
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
}
func docker(ctx context.Context, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.WaitDelay = 2 * time.Second
b, e := cmd.Output()
if e != nil {
return nil, errors.New("Docker operation failed (details suppressed to protect credentials)")
}
return b, nil
}
type container struct {
Image string
Config struct {
Labels map[string]string
Env []string
}
State struct {
Running bool
Health *struct{ Status string }
}
Mounts []struct {
Type, Source, Destination, Name string
RW bool
}
}
func inspect(ctx context.Context, name string) (container, error) {
var c []container
b, e := docker(ctx, "inspect", name)
if e != nil {
return container{}, e
}
if json.Unmarshal(b, &c) != nil || len(c) != 1 {
return container{}, errors.New("invalid Docker inspection")
}
return c[0], nil
}
func (m Manager) Identity(ctx context.Context) (Plan, error) {
p := Plan{DeployDir: m.Dir, Backend: m.Backend}
if !filepath.IsAbs(m.Dir) || filepath.Clean(m.Dir) != m.Dir {
return p, errors.New("deployment directory must be an absolute clean path")
}
c, e := inspect(ctx, m.Backend)
if e != nil {
return p, e
}
p.Image = c.Image
p.Project = c.Config.Labels["com.docker.compose.project"]
if !c.State.Running || !projectPattern.MatchString(p.Project) || c.Config.Labels["com.docker.compose.service"] != "backend" || !strings.HasPrefix(p.Image, "sha256:") {
return p, errors.New("cannot establish original Compose identity")
}
for _, v := range c.Mounts {
if v.Destination == m.Dir && v.Type == "bind" && v.RW {
p.HostDir = v.Source
}
}
if p.HostDir == "/" || !filepath.IsAbs(p.HostDir) || strings.ContainsAny(p.HostDir, ",\n\r") || filepath.Clean(p.HostDir) != p.HostDir {
return p, errors.New("deployment directory must be a writable host bind mount")
}
for _, protected := range []string{"/app/paneld", "/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins/docker-compose", "/var/run/docker.sock", "/bin", "/lib", "/etc", "/proc", "/sys", "/dev"} {
if p.HostDir == protected || strings.HasPrefix(protected, p.HostDir+"/") || strings.HasPrefix(p.HostDir, protected+"/") {
return p, errors.New("host deployment mount would shadow helper runtime paths")
}
}
if c.Config.Labels["com.docker.compose.project.working_dir"] != p.HostDir || c.Config.Labels["com.docker.compose.project.config_files"] != filepath.Join(p.HostDir, "docker-compose.yml") {
return p, errors.New("custom or multiple Compose paths require manual upgrade")
}
return p, nil
}
func (m Manager) read(id string) (*Job, error) {
if !idPattern.MatchString(id) {
return nil, errors.New("invalid job ID")
}
b, e := os.ReadFile(filepath.Join(m.jobDir(id), "status.json"))
if e != nil {
return nil, e
}
var j Job
e = json.Unmarshal(b, &j)
return &j, e
}
func (m Manager) Status(ctx context.Context, id string) (*Job, error) {
if id == "" {
b, e := os.ReadFile(filepath.Join(m.root(), "latest"))
if os.IsNotExist(e) {
return nil, nil
}
if e != nil {
return nil, e
}
id = string(b)
}
j, e := m.read(id)
if e != nil {
return nil, e
}
if !j.Active || time.Now().UnixMilli()-j.UpdatedAt < 90000 {
return j, nil
}
// An unavailable daemon, missing helper, or uncertain launch must never release the task lock.
c, e := inspect(ctx, j.HelperContainer)
if e == nil && c.State.Running {
return j, nil
}
j.RequiresRecovery = true
j.Message = "升级 helper 已停止或无法确认状态。请管理员检查任务备份和容器状态;在确认安全前禁止再次升级。"
// This is a derived view: do not race the helper's durable state writes.
j.Stage = "recovery_required"
return j, nil
}
func (m Manager) write(j *Job, stage, msg string) error {
j.Stage = stage
j.Message = msg
j.UpdatedAt = time.Now().UnixMilli()
j.Active = stage != "succeeded" && stage != "failed"
j.RequiresRecovery = stage == "recovery_required"
if e := save(filepath.Join(m.jobDir(j.ID), "status.json"), j); e != nil {
return e
}
f, e := os.OpenFile(filepath.Join(m.jobDir(j.ID), "events.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if e != nil {
return e
}
defer f.Close()
_, e = fmt.Fprintf(f, "%d %s %s\n", j.UpdatedAt, stage, msg)
return e
}
func (m Manager) Start(ctx context.Context, version string, compose []byte) (*Job, string, error) {
if e := ValidateVersion(version); e != nil {
return nil, "", e
}
unlock, e := m.lock()
if e != nil {
return nil, "", e
}
defer unlock()
old, e := m.Status(ctx, "")
if e != nil {
return nil, "", e
}
if old != nil && (old.Active || old.RequiresRecovery) {
return old, "", errors.New("已有面板升级任务执行中或需要人工恢复")
}
p, e := m.Identity(ctx)
if e != nil {
return nil, "", e
}
b := make([]byte, 16)
if _, e = rand.Read(b); e != nil {
return nil, "", e
}
id := hex.EncodeToString(b)
dir := m.jobDir(id)
if e = os.Mkdir(dir, 0700); e != nil {
return nil, "", e
}
j := Job{ID: id, TargetVersion: version, StartedAt: time.Now().UnixMilli(), HelperContainer: "flvx-upgrade-" + id, BackupDir: filepath.Join(p.HostDir, ".panel-upgrades", id)}
p.Job = j
if e = m.write(&j, "preparing", "正在准备独立升级任务"); e != nil {
return nil, "", e
}
if e = atomic(filepath.Join(m.root(), "latest"), []byte(id)); e != nil {
return nil, "", e
}
fail := func(err error) (*Job, string, error) {
m.write(&j, "failed", "准备失败,线上配置未变更")
return &j, p.Image, err
}
for _, name := range []string{"docker-compose.yml", ".env"} {
data, err := os.ReadFile(filepath.Join(m.Dir, name))
if err != nil {
return fail(err)
}
if err = atomic(filepath.Join(dir, name+".original"), data); err != nil {
return fail(err)
}
}
env, e := os.ReadFile(filepath.Join(dir, ".env.original"))
if e != nil {
return fail(e)
}
lines := strings.Split(string(env), "\n")
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "FLUX_VERSION=") || strings.HasPrefix(strings.TrimSpace(line), "export FLUX_VERSION=") {
lines[i] = ""
}
}
env = []byte(strings.Join(lines, "\n") + "\nFLUX_VERSION=" + version + "\n")
if e = atomic(filepath.Join(dir, ".env"), env); e != nil {
return fail(e)
}
if e = atomic(filepath.Join(dir, "docker-compose.yml"), compose); e != nil {
return fail(e)
}
p.Job = j
if e = save(filepath.Join(dir, "plan.json"), p); e != nil {
return fail(e)
}
// No --rm: an exited helper is evidence needed after a daemon/client timeout.
args := []string{"run", "-d", "--name", j.HelperContainer, "--label", "io.flvx.upgrade-job=" + id, "--mount", "type=bind,src=" + p.HostDir + ",dst=" + p.HostDir, "--mount", "type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock", "--entrypoint", "/app/paneld", p.Image, "panel-upgrade-helper", filepath.Join(p.HostDir, ".panel-upgrades", id, "plan.json")}
if _, e = docker(ctx, args...); e != nil {
c, ie := inspect(ctx, j.HelperContainer)
if ie != nil || !c.State.Running {
return &j, p.Image, errors.New("helper 启动结果不确定;任务已保留,请查询升级状态,勿重复启动")
}
}
return &j, p.Image, nil
}
@@ -0,0 +1,302 @@
package panelupgrade
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func fakeDocker(t *testing.T, body string) {
t.Helper()
d := t.TempDir()
if e := os.WriteFile(filepath.Join(d, "docker"), []byte("#!/bin/sh\n"+body), 0700); e != nil {
t.Fatal(e)
}
t.Setenv("PATH", d+":"+os.Getenv("PATH"))
}
func fixture(t *testing.T) (Manager, Plan) {
t.Helper()
dir := t.TempDir()
m := Manager{Dir: dir, Backend: "flux-panel-backend"}
id := strings.Repeat("a", 32)
os.MkdirAll(m.jobDir(id), 0700)
j := Job{ID: id, TargetVersion: "2.5.0", HelperContainer: "helper", StartedAt: time.Now().UnixMilli()}
m.write(&j, "preparing", "ready")
atomic(filepath.Join(m.root(), "latest"), []byte(id))
p := Plan{Job: j, HostDir: dir, DeployDir: "/opt/flvx-panel", Project: "original-project", Backend: m.Backend, Image: "sha256:old"}
save(filepath.Join(m.jobDir(id), "plan.json"), p)
for _, n := range []string{".env", "docker-compose.yml"} {
atomic(filepath.Join(dir, n), []byte("original"))
atomic(filepath.Join(m.jobDir(id), n+".original"), []byte("original"))
atomic(filepath.Join(m.jobDir(id), n), []byte("target"))
}
return m, p
}
func TestVersionRejectsComposeAndPathInjection(t *testing.T) {
for _, v := range []string{"latest", "../v1", "1.2.3\nSECRET=x", "1.2.3${TOKEN}", "1.2.3 x", "1.2.3;id"} {
if ValidateVersion(v) == nil {
t.Fatal(v)
}
}
for _, v := range []string{"2.5.0-rc10", "v2.5.0", "2.1.9-beta6"} {
if ValidateVersion(v) != nil {
t.Fatal(v)
}
}
}
func TestPersistentLockAcrossManagers(t *testing.T) {
m, p := fixture(t)
fakeDocker(t, "exit 1\n")
_, _, e := m.Start(context.Background(), "2.5.0", nil)
if e == nil {
t.Fatal("accepted concurrent job")
}
b, _ := os.ReadFile(filepath.Join(m.root(), "latest"))
if string(b) != p.Job.ID {
t.Fatal("changed active job")
}
}
func TestUncertainHelperNeverUnlocks(t *testing.T) {
m, p := fixture(t)
j, _ := m.read(p.Job.ID)
j.UpdatedAt = 1
save(filepath.Join(m.jobDir(j.ID), "status.json"), j)
fakeDocker(t, "exit 1\n")
j, e := m.Status(context.Background(), "")
if e != nil || !j.Active || !j.RequiresRecovery || j.Stage != "recovery_required" {
t.Fatalf("%+v %v", j, e)
}
if _, _, e = m.Start(context.Background(), "2.5.0", nil); e == nil {
t.Fatal("uncertain launch unlocked")
}
}
func configs(p Plan) (string, string) {
old := composeConfig{}
json.Unmarshal([]byte(`{"services":{"backend":{"image":"old","container_name":"flux-panel-backend","environment":{"FLUX_VERSION":"old"}},"frontend":{"image":"old","container_name":"vite-frontend"}}}`), &old)
a, _ := json.Marshal(old)
next := old
next.Services = make(map[string]struct {
Image string `json:"image"`
ContainerName string `json:"container_name"`
Volumes []map[string]any `json:"volumes"`
Environment map[string]string `json:"environment"`
})
for k, v := range old.Services {
next.Services[k] = v
}
v := next.Services["backend"]
v.Image = "ghcr.io/sagit-chu/flux-panel-backend:" + p.Job.TargetVersion
v.Environment = map[string]string{"FLUX_VERSION": p.Job.TargetVersion}
next.Services["backend"] = v
v = next.Services["frontend"]
v.Image = "ghcr.io/sagit-chu/vite-frontend:" + p.Job.TargetVersion
next.Services["frontend"] = v
b, _ := json.Marshal(next)
return string(a), string(b)
}
func TestHelperFailureBoundaries(t *testing.T) {
for _, stage := range []string{"config", "pull", "apply", "up", "health", "success"} {
t.Run(stage, func(t *testing.T) {
m, p := fixture(t)
old, next := configs(p)
dir := m.jobDir(p.Job.ID)
if stage == "apply" {
os.Remove(filepath.Join(dir, ".env"))
os.Mkdir(filepath.Join(dir, ".env"), 0700)
}
if stage == "health" {
before := verificationTimeout
verificationTimeout = 0
t.Cleanup(func() { verificationTimeout = before })
}
script := `case "$*" in
*"config --format json"*)
case "$*" in *"` + p.Job.ID + `"*) echo '` + next + `';; *) echo '` + old + `';; esac
;;
*"pull backend frontend"*) `
if stage == "pull" {
script += "echo SECRET >&2; exit 1"
} else {
script += "exit 0"
}
script += `;;
"image inspect"*) echo sha256:target;;
*"up -d"*) `
if stage == "up" {
script += "exit 1"
} else {
script += "touch \"" + filepath.Join(dir, "recreated") + "\"; exit 0"
}
script += `;;
"inspect flux-panel-backend") if [ -f "` + filepath.Join(dir, "recreated") + `" ]; then IMG=sha256:target; else IMG=sha256:old; fi; echo '[{"Image":"'"$IMG"'","State":{"Running":true,"Health":{"Status":"healthy"}},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}}}]';;
"inspect vite-frontend") echo '[{"Image":"sha256:target","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"frontend"}}}]';;
"exec vite-frontend wget"*) exit 0;;
*) exit 1;; esac
`
if stage == "config" {
script = "echo SECRET >&2;exit 1\n"
}
fakeDocker(t, script)
e := RunHelper(filepath.Join(dir, "plan.json"))
j, _ := m.read(p.Job.ID)
want := "failed"
if stage == "up" || stage == "health" {
want = "recovery_required"
}
if stage == "success" {
want = "succeeded"
if e != nil {
t.Fatal(e)
}
}
if j.Stage != want {
t.Fatalf("stage=%s error=%v", j.Stage, e)
}
for _, n := range []string{".env", "docker-compose.yml"} {
b, _ := os.ReadFile(filepath.Join(m.Dir, n))
expected := "original"
if stage == "up" || stage == "success" || stage == "health" {
expected = "target"
}
if string(b) != expected {
t.Fatalf("%s mutated unexpectedly", n)
}
b, _ = os.ReadFile(filepath.Join(dir, n+".original"))
if string(b) != "original" {
t.Fatal("backup overwritten")
}
}
b, _ := os.ReadFile(filepath.Join(dir, "events.log"))
if strings.Contains(string(b), "SECRET") {
t.Fatal("secret leaked")
}
})
}
}
func TestComposeUsesOriginalHostPathAndProject(t *testing.T) {
p := Plan{Project: "my-project", HostDir: "/srv/panel"}
s := strings.Join(composeArgs(p, "/srv/panel/.panel-upgrades/job", "pull", "backend", "frontend"), " ")
if !strings.Contains(s, "--project-name my-project --project-directory /srv/panel") || strings.Contains(s, "remove-orphans") {
t.Fatal(s)
}
}
func TestStatusWithoutJobNeedsNoDocker(t *testing.T) {
fakeDocker(t, "exit 99\n")
j, e := (Manager{Dir: t.TempDir()}).Status(context.Background(), "")
if e != nil || j != nil {
t.Fatalf("%v %v", j, e)
}
}
func TestFullConfigRejectsUnintendedChanges(t *testing.T) {
for _, field := range []string{"ports", "network_mode", "command", "entrypoint", "privileged", "networks"} {
t.Run(field, func(t *testing.T) {
_, p := fixture(t)
oldJSON, nextJSON := configs(p)
var old, next composeConfig
json.Unmarshal([]byte(oldJSON), &old)
json.Unmarshal([]byte(nextJSON), &next)
var a, b struct {
Services map[string]map[string]any `json:"services"`
}
json.Unmarshal([]byte(oldJSON), &a)
json.Unmarshal([]byte(nextJSON), &b)
old.FullServices = a.Services
next.FullServices = b.Services
old.FullServices["frontend"][field] = "private-custom-setting"
if validateConfig(old, next, p) == nil {
t.Fatal("accepted service setting removal: " + field)
}
})
}
}
func TestLiveRejectsShadowMount(t *testing.T) {
_, p := fixture(t)
fakeDocker(t, `echo '[{"Image":"sha256:old","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}},"Mounts":[{"Type":"bind","Source":"/srv/db/gost.db","Destination":"/app/data/gost.db","RW":true}]}]'`)
oldJSON, _ := configs(p)
var old composeConfig
json.Unmarshal([]byte(oldJSON), &old)
if validateLive(context.Background(), p, old) == nil {
t.Fatal("accepted undeclared shadow mount")
}
}
func identityJSON(dir, host, project, files string) string {
c := map[string]any{"Image": "sha256:old", "State": map[string]any{"Running": true}, "Config": map[string]any{"Labels": map[string]string{"com.docker.compose.project": project, "com.docker.compose.service": "backend", "com.docker.compose.project.working_dir": host, "com.docker.compose.project.config_files": files}}, "Mounts": []map[string]any{{"Type": "bind", "Source": host, "Destination": dir, "RW": true}}}
b, _ := json.Marshal([]any{c})
return string(b)
}
func TestStartUsesHostMountAndKeepsUniqueBackups(t *testing.T) {
dir := t.TempDir()
m := Manager{Dir: dir, Backend: "backend"}
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
os.WriteFile(filepath.Join(dir, n), []byte("original-secret"), 0600)
}
args := filepath.Join(t.TempDir(), "args")
fakeDocker(t, `case "$1" in inspect) echo '`+identityJSON(dir, "/srv/original-panel", "original-project", "/srv/original-panel/docker-compose.yml")+`';; run) printf '%s\n' "$@" > '`+args+`'; echo container-id;; *) exit 1;; esac`)
j, _, e := m.Start(context.Background(), "2.5.0-rc10", []byte("target"))
if e != nil {
t.Fatal(e)
}
b, _ := os.ReadFile(args)
s := string(b)
for _, expected := range []string{"type=bind,src=/srv/original-panel,dst=/srv/original-panel", "/app/paneld", "sha256:old", "panel-upgrade-helper"} {
if !strings.Contains(s, expected) {
t.Fatal(s)
}
}
if strings.Contains(s, "--rm") || strings.Contains(s, "--volumes-from") {
t.Fatal(s)
}
first := j.ID
m.write(j, "failed", "safe failure")
j, _, e = m.Start(context.Background(), "2.5.1", []byte("target2"))
if e != nil || j.ID == first {
t.Fatalf("%v %v", j, e)
}
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
b, _ := os.ReadFile(filepath.Join(dir, n))
if string(b) != "original-secret" {
t.Fatal("live or legacy overwritten")
}
}
b, _ = os.ReadFile(filepath.Join(m.jobDir(first), ".env.original"))
if string(b) != "original-secret" {
t.Fatal("old job backup changed")
}
}
func TestIdentityFailsClosed(t *testing.T) {
for _, host := range []string{"/", "/app", "/usr", "/var/run", "/proc", "relative", "/srv/bad,host"} {
t.Run(host, func(t *testing.T) {
dir := t.TempDir()
fakeDocker(t, "echo '"+identityJSON(dir, host, "project", filepath.Join(host, "docker-compose.yml"))+"'\n")
if _, e := (Manager{Dir: dir, Backend: "backend"}).Identity(context.Background()); e == nil {
t.Fatal(host)
}
})
}
}
func TestUncertainRunIsNotRetriedOrUnlocked(t *testing.T) {
dir := t.TempDir()
m := Manager{Dir: dir, Backend: "backend"}
for _, n := range []string{".env", "docker-compose.yml"} {
os.WriteFile(filepath.Join(dir, n), []byte("original"), 0600)
}
calls := filepath.Join(t.TempDir(), "runs")
fakeDocker(t, `case "$*" in "inspect backend") echo '`+identityJSON(dir, "/srv/panel", "project", "/srv/panel/docker-compose.yml")+`';; run*) echo run >> '`+calls+`'; exit 1;; *) exit 1;; esac`)
j, _, e := m.Start(context.Background(), "2.5.0", []byte("target"))
if e == nil || j == nil || !j.Active {
t.Fatalf("%v %v", j, e)
}
_, _, e = m.Start(context.Background(), "2.5.0", nil)
if e == nil {
t.Fatal("retry accepted")
}
b, _ := os.ReadFile(calls)
if string(b) != "run\n" {
t.Fatal("retried uncertain docker run")
}
}
+4
View File
@@ -10,6 +10,7 @@ import "database/sql"
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
// word, so TableName() is required for correct quoting.
type User struct {
DisplayOrder int64 `gorm:"not null;default:0"`
ID int64 `gorm:"primaryKey;autoIncrement"`
User string `gorm:"column:user;type:varchar(100);not null"`
Pwd string `gorm:"type:varchar(100);not null"`
@@ -151,6 +152,7 @@ type NftCounterState struct {
func (NftCounterState) TableName() string { return "nft_counter_state" }
type SpeedLimit struct {
DisplayOrder int64 `gorm:"not null;default:0"`
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Speed int `gorm:"not null"`
@@ -417,6 +419,7 @@ type BackupData struct {
}
type UserBackup struct {
DisplayOrder *int64 `json:"displayOrder,omitempty"`
ID int64 `json:"id"`
User string `json:"user"`
Pwd string `json:"pwd"`
@@ -540,6 +543,7 @@ type UserTunnelBackup struct {
}
type SpeedLimitBackup struct {
DisplayOrder *int64 `json:"displayOrder,omitempty"`
ID int64 `json:"id"`
Name string `json:"name"`
Speed int64 `json:"speed"`
+43 -13
View File
@@ -419,7 +419,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
}
if m.HasTable(&model.Tunnel{}) {
for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort"} {
for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort", "RelayAuthMode"} {
if m.HasColumn(&model.Tunnel{}, field) {
continue
}
@@ -914,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
return nil, errors.New("repository not initialized")
}
var users []model.User
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
if err := r.db.Where("role_id != ?", 0).Order("display_order ASC, id DESC").Find(&users).Error; err != nil {
return nil, err
}
userIDs := make([]int64, 0, len(users))
@@ -954,7 +954,7 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
return nil, errors.New("repository not initialized")
}
var limits []model.SpeedLimit
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
if err := r.db.Order("display_order ASC, id DESC").Find(&limits).Error; err != nil {
return nil, err
}
items := make([]map[string]interface{}, 0, len(limits))
@@ -1001,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
var rows []fwdRow
err := r.db.Model(&model.Forward{}).
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
Select("forward.id, forward.user_id, COALESCE(owner.\"user\", forward.user_name) AS user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id").
Order("forward.inx ASC, forward.id ASC").
@@ -2092,6 +2093,7 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
out := make([]model.UserBackup, 0, len(users))
for _, u := range users {
b := model.UserBackup{
DisplayOrder: &u.DisplayOrder,
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
FlowResetTime: u.FlowResetTime, Num: u.Num,
@@ -2289,6 +2291,7 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
out := make([]model.SpeedLimitBackup, 0, len(sls))
for _, sl := range sls {
b := model.SpeedLimitBackup{
DisplayOrder: &sl.DisplayOrder,
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
CreatedTime: sl.CreatedTime, Status: sl.Status,
}
@@ -2372,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
typeSet[t] = true
}
err := r.db.Transaction(func(tx *gorm.DB) error {
err := backupImportTransaction(r.db, func(tx *gorm.DB) error {
result = &model.ImportResult{}
tables, err := lockPostgresBackupTables(tx, backup, typeSet)
if err != nil {
return err
}
// SQLite still needs its writer reservation for display order.
// PostgreSQL membership locks were taken with all imported tables above.
if tx.Dialector.Name() != "postgres" && typeSet["users"] {
if err := lockDisplayOrder(tx, "user"); err != nil {
return err
}
}
if tx.Dialector.Name() != "postgres" && typeSet["speedLimits"] {
if err := lockDisplayOrder(tx, "speed_limit"); err != nil {
return err
}
}
now := unixMilliNow()
if typeSet["users"] && len(backup.Users) > 0 {
@@ -2445,7 +2465,7 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
}
result.ConfigsImported = count
}
return nil
return advancePostgresBackupSequences(tx, tables)
})
if err != nil {
return nil, err
@@ -2477,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
Status: status,
PasswordChangedAt: now,
}
// An omitted old-backup rank preserves existing order on conflict.
// New legacy records use rank zero and ID descending, matching upgrades.
columns := []string{"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
"flow_reset_time", "num", "updated_time", "status", "password_changed_at"}
if u.DisplayOrder != nil {
item.DisplayOrder = *u.DisplayOrder
columns = append(columns, "display_order")
}
err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
}),
DoUpdates: clause.AssignmentColumns(columns),
}).Create(&item).Error
if err != nil {
return count, err
@@ -2765,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: sl.Status,
}
// An omitted old-backup rank preserves existing order on conflict.
// New legacy records use rank zero and ID descending, matching upgrades.
columns := []string{"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status"}
if sl.DisplayOrder != nil {
item.DisplayOrder = *sl.DisplayOrder
columns = append(columns, "display_order")
}
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status",
}),
DoUpdates: clause.AssignmentColumns(columns),
}).Create(&item).Error
if err != nil {
return count, err
@@ -0,0 +1,195 @@
package repo
import (
"errors"
"fmt"
"time"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
var errBackupImportBusy = errors.New("database is busy; retry the backup import")
// Only retry lock acquisition failures, which occur before any backup writes.
// NOWAIT releases partial lock sets instead of deadlocking with existing
// child-before-parent deletes or forward-before-user traffic updates.
func backupImportTransaction(db *gorm.DB, importRows func(*gorm.DB) error) error {
var err error
for attempt := 0; attempt < 10; attempt++ {
err = db.Transaction(importRows)
if !errors.Is(err, errBackupImportBusy) {
return err
}
if attempt < 9 {
time.Sleep(min(10*time.Millisecond<<attempt, 100*time.Millisecond))
}
}
return err
}
// Lock every table that import can write, including generated-ID child tables.
// No writes happen until the complete set is held. Existing CRUD transactions
// can therefore finish regardless of their own parent/child mutation order.
// The returned subset contains only tables receiving explicit backup IDs.
func lockPostgresBackupTables(tx *gorm.DB, backup *model.BackupData, types map[string]bool) ([]string, error) {
if tx.Dialector.Name() != "postgres" {
return nil, nil
}
chains, grants, ports := 0, 0, 0
for _, tunnel := range backup.Tunnels {
chains += len(tunnel.ChainTunnels)
}
for _, permission := range backup.Permissions {
grants += len(permission.Grants)
}
for _, forward := range backup.Forwards {
if forward.ForwardPorts != nil {
ports++
}
}
var tables []string
for _, item := range []struct {
kind string
count int
name string
explicitIDs bool
}{
{"users", len(backup.Users), "user", true},
{"speedLimits", len(backup.SpeedLimits), "speed_limit", true},
{"nodes", len(backup.Nodes), "node", true},
{"tunnels", len(backup.Tunnels), "tunnel", true},
{"tunnels", chains, "chain_tunnel", true},
{"forwards", len(backup.Forwards), "forward", true},
{"userTunnels", len(backup.UserTunnels), "user_tunnel", true},
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group", true},
{"userGroups", len(backup.UserGroups), "user_group", true},
{"permissions", len(backup.Permissions), "group_permission", true},
{"permissions", grants, "group_permission_grant", true},
{"users", len(backup.Users), "user_quota", false},
{"forwards", ports, "forward_port", false},
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group_tunnel", false},
{"userGroups", len(backup.UserGroups), "user_group_user", false},
{"configs", len(FilterBackupConfigs(backup.Configs)), "vite_config", false},
} {
if !types[item.kind] || item.count == 0 {
continue
}
// Resolve the same relation as normal unqualified repository queries,
// even when the first search_path schema has no copy of this table.
var relation struct {
Schema string
Name string
}
if err := tx.Raw(`SELECT n.nspname AS schema, c.relname AS name
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = pg_catalog.to_regclass(?)`, quoteSQLIdentifier(item.name)).Scan(&relation).Error; err != nil {
return nil, err
}
if relation.Name == "" {
return nil, fmt.Errorf("missing import table %s", item.name)
}
table := quoteSQLIdentifier(relation.Schema) + "." + quoteSQLIdentifier(relation.Name)
if err := tx.Exec("LOCK TABLE " + table + " IN SHARE ROW EXCLUSIVE MODE NOWAIT").Error; err != nil {
var pgError interface{ SQLState() string }
if errors.As(err, &pgError) && pgError.SQLState() == "55P03" {
return nil, fmt.Errorf("%w: %w", errBackupImportBusy, err)
}
return nil, err
}
if item.explicitIDs {
tables = append(tables, table)
}
}
return tables, nil
}
func advancePostgresBackupSequences(tx *gorm.DB, tables []string) error {
for _, table := range tables {
if err := advancePostgresBackupSequence(tx, table); err != nil {
return fmt.Errorf("advance imported %s id sequence: %w", table, err)
}
}
return nil
}
// The caller holds the table's SHARE ROW EXCLUSIVE lock until commit. Unlike
// startup's setval repair, RESTART is transactional and invalidates other
// connections' cached values. Never return to IDs already allocated, even if
// their rows were deleted or their transactions rolled back.
func advancePostgresBackupSequence(tx *gorm.DB, table string) error {
var maxID int64
if err := tx.Raw("SELECT COALESCE(MAX(id), 0) FROM " + table).Scan(&maxID).Error; err != nil {
return err
}
if maxID <= 0 {
return nil
}
type sequenceInfo struct {
Schema string
Name string
Cache int64
Increment int64
Maximum int64
}
var sequences []sequenceInfo
// Prefer the actual default's catalog dependency: a legacy custom nextval
// default need not own its sequence, and the old owned serial may remain.
// Identity columns have no pg_attrdef, so use their owned sequence instead.
if err := tx.Raw(`WITH defaults AS (
SELECT dep.refobjid AS oid FROM pg_catalog.pg_attrdef def
JOIN pg_catalog.pg_attribute attr ON attr.attrelid = def.adrelid AND attr.attnum = def.adnum
JOIN pg_catalog.pg_depend dep ON dep.classid = 'pg_catalog.pg_attrdef'::regclass
AND dep.objid = def.oid AND dep.refclassid = 'pg_catalog.pg_class'::regclass
JOIN pg_catalog.pg_sequence seq ON seq.seqrelid = dep.refobjid
WHERE def.adrelid = ?::regclass AND attr.attname = 'id'
), candidates AS (
SELECT oid FROM defaults
UNION SELECT pg_catalog.pg_get_serial_sequence(?, 'id')::regclass WHERE NOT EXISTS (SELECT 1 FROM defaults)
)
SELECT n.nspname AS schema, c.relname AS name,
s.seqcache AS cache, s.seqincrement AS increment, s.seqmax AS maximum
FROM pg_catalog.pg_sequence s
JOIN pg_catalog.pg_class c ON c.oid = s.seqrelid
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE s.seqrelid IN (SELECT oid FROM candidates)`, table, table).Scan(&sequences).Error; err != nil {
return err
}
if len(sequences) != 1 || sequences[0].Increment <= 0 {
return fmt.Errorf("missing or ambiguous ascending ID sequence")
}
sequence := sequences[0]
name := quoteSQLIdentifier(sequence.Schema) + "." + quoteSQLIdentifier(sequence.Name)
// Reapplying CACHE without changing its setting takes a sequence DDL lock.
// Read last_value only after that lock: even direct concurrent nextval calls
// must finish first. The table locks already exclude normal INSERTs.
if err := tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s CACHE %d", name, sequence.Cache)).Error; err != nil {
return err
}
var state struct {
LastValue int64
IsCalled bool
}
if err := tx.Raw("SELECT last_value, is_called FROM " + name).Scan(&state).Error; err != nil {
return err
}
if state.LastValue < 1 {
return fmt.Errorf("ID sequence must allocate positive IDs")
}
if maxID >= sequence.Maximum || (state.IsCalled && state.LastValue > sequence.Maximum-sequence.Increment) {
return fmt.Errorf("ID sequence exhausted")
}
next := state.LastValue
if state.IsCalled {
next += sequence.Increment
}
if next <= maxID {
steps := (maxID-next)/sequence.Increment + 1
if steps > (sequence.Maximum-next)/sequence.Increment {
return fmt.Errorf("ID sequence exhausted")
}
next += steps * sequence.Increment
}
return tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", name, next)).Error
}
@@ -37,8 +37,14 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
if tx == nil {
return nil, errors.New("database unavailable")
}
var forwards []model.Forward
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
var forwards []struct {
model.Forward
CurrentUserName string
}
err := tx.Model(&model.Forward{}).
Select(`forward.*, COALESCE(owner."user", forward.user_name) AS current_user_name`).
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
Where("forward.tunnel_id = ?", tunnelID).Order("forward.id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
@@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
UserName: f.CurrentUserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
@@ -0,0 +1,109 @@
package repo
import (
"errors"
"gorm.io/gorm"
"slices"
)
var ErrDisplayOrderConflict = errors.New("列表已变更,请刷新后重试")
var ErrInvalidDisplayOrder = errors.New("排序必须包含全部条目且不能重复")
// Lock the entire membership, including an empty list and concurrent INSERTs.
// PostgreSQL row locks alone cannot protect the missing-row/phantom case.
// SQLite's first write acquires its writer reservation before reading the list.
func lockDisplayOrder(tx *gorm.DB, table string) error {
if tx.Dialector.Name() == "postgres" {
return tx.Exec("LOCK TABLE \"" + table + "\" IN SHARE ROW EXCLUSIVE MODE").Error
}
return tx.Table(table).Where("1 = 0").UpdateColumn("display_order", gorm.Expr("display_order")).Error
}
func (r *Repository) UpdateUserDisplayOrder(ids, expected []int64) error {
return r.updateDisplayOrder("user", ids, expected)
}
func (r *Repository) UpdateSpeedLimitDisplayOrder(ids, expected []int64) error {
return r.updateDisplayOrder("speed_limit", ids, expected)
}
func (r *Repository) updateDisplayOrder(table string, ids, expected []int64) error {
if ids == nil || expected == nil {
return ErrInvalidDisplayOrder
}
seen := map[int64]bool{}
for _, id := range ids {
if id <= 0 || seen[id] {
return ErrInvalidDisplayOrder
}
seen[id] = true
}
seenExpected := map[int64]bool{}
for _, id := range expected {
if id <= 0 || seenExpected[id] {
return ErrInvalidDisplayOrder
}
seenExpected[id] = true
}
if len(ids) != len(expected) {
return ErrInvalidDisplayOrder
}
for id := range seen {
if !seenExpected[id] {
return ErrInvalidDisplayOrder
}
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockDisplayOrder(tx, table); err != nil {
return err
}
q := tx.Table(table)
if table == "user" {
var admins int64
if err := tx.Table(table).Where("role_id = ? AND id IN ?", 0, ids).Count(&admins).Error; err != nil {
return err
}
if admins != 0 {
return ErrInvalidDisplayOrder
}
q = q.Where("role_id != ?", 0)
}
var current []int64
if err := q.Order("display_order ASC, id DESC").Pluck("id", &current).Error; err != nil {
return err
}
if !slices.Equal(current, expected) {
return ErrDisplayOrderConflict
}
for i, id := range ids {
result := tx.Table(table).Where("id = ?", id).UpdateColumn("display_order", int64(i))
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrDisplayOrderConflict
}
}
return nil
})
}
// New items precede all saved ranks. Zero is only the legacy migration default.
func createFirst(tx *gorm.DB, table string, item interface{}, rank *int64) error {
return tx.Transaction(func(tx *gorm.DB) error {
if err := lockDisplayOrder(tx, table); err != nil {
return err
}
q := tx.Table(table)
if table == "user" {
q = q.Where("role_id != ?", 0)
}
var first int64
if err := q.Select("COALESCE(MIN(display_order), 0)").Scan(&first).Error; err != nil {
return err
}
if first == -1<<63 {
return errors.New("display order exhausted")
}
*rank = first - 1
return tx.Create(item).Error
})
}
@@ -65,7 +65,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
Status: status,
PasswordChangedAt: now,
}
if err := r.db.Create(&user).Error; err != nil {
if err := createFirst(r.db, "user", &user, &user.DisplayOrder); err != nil {
return 0, err
}
return user.ID, nil
@@ -940,7 +940,7 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
}
if err := r.db.Create(&sl).Error; err != nil {
if err := createFirst(r.db, "speed_limit", &sl, &sl.DisplayOrder); err != nil {
return 0, err
}
return sl.ID, nil
@@ -2,6 +2,8 @@ package repo
import (
"errors"
"strconv"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/model"
"gorm.io/gorm"
@@ -22,7 +24,8 @@ func (r *Repository) RelayEndpointCredentials(tx *gorm.DB, tunnelID, nodeID int6
err := find()
if errors.Is(err, gorm.ErrRecordNotFound) && create {
var existing int64
if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).Count(&existing).Error; e != nil {
// chain_tunnel stores roles as text; relay_endpoint_auth uses integers.
if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, strconv.Itoa(role)).Count(&existing).Error; e != nil {
return nil, e
}
if existing > 0 {
@@ -0,0 +1,179 @@
package repo
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
gsqlite "github.com/glebarez/sqlite"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestOpenBackfillsSQLiteTunnelRelayAuthMode(t *testing.T) {
for _, version := range []int{0, currentSchemaVersion} {
t.Run(fmt.Sprintf("schema_version_%d", version), func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-relay.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)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get legacy sqlite connection: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
// This is the tunnel schema immediately before relay authentication.
if err := db.Exec(`
CREATE TABLE 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,
ip_preference VARCHAR(10) NOT NULL DEFAULT '',
probe_target_host TEXT NOT NULL DEFAULT '',
probe_target_port INTEGER NOT NULL DEFAULT 0
)
`).Error; err != nil {
t.Fatalf("create legacy tunnel table: %v", err)
}
legacy := model.Tunnel{
ID: 41, Name: "existing-relay", TrafficRatio: 1.5,
Type: 2, Protocol: "tcp", Flow: 123,
CreatedTime: 111, UpdatedTime: 222, Status: 1,
InIP: sql.NullString{String: "192.0.2.1", Valid: true},
Inx: 3, IPPreference: "ipv4",
ProbeTargetHost: "example.com", ProbeTargetPort: 8443,
}
if err := db.Omit("RelayAuthMode").Create(&legacy).Error; err != nil {
t.Fatalf("seed legacy tunnel: %v", err)
}
if err := db.AutoMigrate(&model.SchemaVersion{}); err != nil {
t.Fatalf("create schema version table: %v", err)
}
if err := db.Create(&model.SchemaVersion{Version: version}).Error; err != nil {
t.Fatalf("seed schema version: %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close legacy sqlite: %v", err)
}
r, err := Open(dbPath)
if err != nil {
t.Fatalf("open migrated sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
if !r.DB().Migrator().HasColumn(&model.Tunnel{}, "RelayAuthMode") {
t.Fatal("expected tunnel.relay_auth_mode after upgrading an existing database")
}
legacy.RelayAuthMode = relaysecurity.Legacy
assertSQLiteRelayTunnel(t, r, legacy)
var createdID int64
if err := r.DB().Transaction(func(tx *gorm.DB) error {
var err error
createdID, err = r.CreateTunnelTx(tx, "after-upgrade", 1, 1, 1, 333, 1, nil, 0, "", "", 0)
return err
}); err != nil {
t.Fatalf("create tunnel after upgrade: %v", err)
}
if createdID <= legacy.ID {
t.Fatalf("expected new tunnel ID above %d, got %d", legacy.ID, createdID)
}
var created model.Tunnel
if err := r.DB().First(&created, createdID).Error; err != nil {
t.Fatalf("read newly created tunnel: %v", err)
}
if created.RelayAuthMode != relaysecurity.Legacy {
t.Fatalf("expected legacy default for created tunnel, got %q", created.RelayAuthMode)
}
if err := r.DB().Transaction(func(tx *gorm.DB) error {
return r.SetTunnelRelayAuthModeTx(tx, createdID, relaysecurity.Required)
}); err != nil {
t.Fatalf("enable relay authentication: %v", err)
}
created.RelayAuthMode = relaysecurity.Required
// Startup repair must be idempotent and never downgrade required mode.
for attempt := 0; attempt < 2; attempt++ {
if err := r.Close(); err != nil {
t.Fatalf("close migrated sqlite: %v", err)
}
r, err = Open(dbPath)
if err != nil {
t.Fatalf("reopen migrated sqlite: %v", err)
}
assertSQLiteRelayTunnel(t, r, legacy)
assertSQLiteRelayTunnel(t, r, created)
}
var count int64
if err := r.DB().Model(&model.Tunnel{}).Count(&count).Error; err != nil {
t.Fatalf("count migrated tunnels: %v", err)
}
if count != 2 {
t.Fatalf("expected two tunnels after repeated startup, got %d", count)
}
var integrity string
if err := r.DB().Raw("PRAGMA integrity_check").Scan(&integrity).Error; err != nil {
t.Fatalf("check migrated database integrity: %v", err)
}
if integrity != "ok" {
t.Fatalf("unexpected database integrity result: %s", integrity)
}
})
}
}
func TestOpenNewSQLiteTunnelRelayAuthMode(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "new-relay.db")
r, err := Open(dbPath)
if err != nil {
t.Fatalf("open new sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
if !r.DB().Migrator().HasColumn(&model.Tunnel{}, "RelayAuthMode") {
t.Fatal("expected tunnel.relay_auth_mode on a fresh database")
}
// Omit the field explicitly to test SQLite's default, not GORM's default.
tunnel := model.Tunnel{
Name: "new-tunnel", TrafficRatio: 1, Type: 1, Protocol: "tls",
Flow: 1, CreatedTime: 111, UpdatedTime: 111, Status: 1,
}
if err := r.DB().Omit("RelayAuthMode").Create(&tunnel).Error; err != nil {
t.Fatalf("create tunnel without explicit relay mode: %v", err)
}
tunnel.RelayAuthMode = relaysecurity.Legacy
assertSQLiteRelayTunnel(t, r, tunnel)
if err := r.Close(); err != nil {
t.Fatalf("close new sqlite: %v", err)
}
r, err = Open(dbPath)
if err != nil {
t.Fatalf("reopen new sqlite: %v", err)
}
assertSQLiteRelayTunnel(t, r, tunnel)
}
func assertSQLiteRelayTunnel(t *testing.T, r *Repository, want model.Tunnel) {
t.Helper()
var got model.Tunnel
if err := r.DB().First(&got, want.ID).Error; err != nil {
t.Fatalf("read tunnel %d: %v", want.ID, err)
}
if got != want {
t.Fatalf("tunnel data changed: got %+v, want %+v", got, want)
}
}
@@ -0,0 +1,98 @@
package repo
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"gorm.io/gorm"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/model"
)
func TestRelayEndpointCredentialsBindsLegacyChainTypeAsText(t *testing.T) {
for _, role := range []int{2, 3} {
t.Run(strconv.Itoa(role), func(t *testing.T) {
r, err := Open(filepath.Join(t.TempDir(), "relay.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
// SQLite coerces integer parameters for this legacy text column.
// Check the actual repository query with pgx's text encoder as well,
// so the PostgreSQL regression is caught without a running server.
checked := 0
if err := r.DB().Callback().Query().After("gorm:query").Register("test:relay_chain_text_parameter", func(db *gorm.DB) {
if db.Statement.Table != "chain_tunnel" {
return
}
checked++
if len(db.Statement.Vars) != 3 {
db.AddError(fmt.Errorf("expected three chain lookup arguments, got %d", len(db.Statement.Vars)))
return
}
encoded, err := pgtype.NewMap().Encode(pgtype.TextOID, pgtype.TextFormatCode, db.Statement.Vars[2], nil)
if err != nil {
db.AddError(fmt.Errorf("encode chain_type for PostgreSQL: %w", err))
} else if string(encoded) != strconv.Itoa(role) {
db.AddError(fmt.Errorf("unexpected chain_type argument: %q", encoded))
}
}); err != nil {
t.Fatal(err)
}
tunnel := model.Tunnel{Name: "relay", Type: 2, Flow: 1, RelayAuthMode: relaysecurity.Required}
if err := r.DB().Create(&tunnel).Error; err != nil {
t.Fatal(err)
}
const nodeID = int64(7)
if err := r.DB().Transaction(func(tx *gorm.DB) error {
created, err := r.RelayEndpointCredentials(tx, tunnel.ID, nodeID, role, true)
if err != nil {
return err
}
for _, create := range []bool{false, true} {
loaded, err := r.RelayEndpointCredentials(tx, tunnel.ID, nodeID, role, create)
if err != nil {
return err
}
if *created != *loaded {
return fmt.Errorf("reloading endpoint changed credentials")
}
}
return nil
}); err != nil {
t.Fatalf("create and reload endpoint: %v", err)
}
if checked != 1 {
t.Fatalf("expected one chain lookup for a new endpoint, got %d", checked)
}
chain := model.ChainTunnel{TunnelID: tunnel.ID, NodeID: nodeID, ChainType: strconv.Itoa(role)}
if err := r.DB().Create(&chain).Error; err != nil {
t.Fatal(err)
}
if err := r.DB().Where("tunnel_id = ?", tunnel.ID).Delete(&model.RelayEndpointAuth{}).Error; err != nil {
t.Fatal(err)
}
if _, err := r.RelayEndpointCredentials(nil, tunnel.ID, nodeID, role, true); err == nil || !strings.Contains(err.Error(), "refusing regeneration") {
t.Fatalf("missing protected credentials must fail closed, got %v", err)
}
if checked != 2 {
t.Fatalf("expected chain lookup before refusing regeneration, got %d", checked)
}
var count int64
if err := r.DB().Model(&model.RelayEndpointAuth{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("regenerated credentials for an existing protected endpoint")
}
})
}
}
@@ -0,0 +1,144 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func orderRequest(t *testing.T, router http.Handler, token, resource, body string) response.R {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/"+resource+"/update-order", bytes.NewBufferString(body))
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var out response.R
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (%s)", err, w.Body.String())
}
return out
}
func TestDisplayOrderUserContract(t *testing.T) {
router, r := setupContractRouter(t, "order-secret")
token := mustAdminToken(t, "order-secret")
a, err := r.CreateUser("order-a", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
if err != nil {
t.Fatal(err)
}
b, err := r.CreateUser("order-b", "hash", 1, 0, 20, 1, 2, 1, 3, 0)
if err != nil {
t.Fatal(err)
}
out := orderRequest(t, router, token, "user", fmt.Sprintf(`{"ids":[%d,%d],"expectedIds":[%d,%d]}`, a, b, b, a))
if out.Code != 0 {
t.Fatalf("reorder: %+v", out)
}
users, err := r.ListUsers()
if err != nil {
t.Fatal(err)
}
if got := []int64{users[0]["id"].(int64), users[1]["id"].(int64)}; !reflect.DeepEqual(got, []int64{a, b}) {
t.Fatalf("order %v", got)
}
}
func TestDisplayOrderValidationAndLifecycle(t *testing.T) {
for _, resource := range []string{"user", "speed-limit"} {
t.Run(resource, func(t *testing.T) {
router, r := setupContractRouter(t, "order-secret")
token := mustAdminToken(t, "order-secret")
create := func(name string) int64 {
t.Helper()
var id int64
var err error
if resource == "user" {
id, err = r.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
} else {
id, err = r.CreateSpeedLimit(name, 100, 0, 1)
}
if err != nil {
t.Fatal(err)
}
return id
}
list := func() []int64 {
t.Helper()
out := postBatchRequest(t, router, token, "/api/v1/"+resource+"/list", "{}")
rows, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("list data: %T", out.Data)
}
ids := []int64{}
for _, row := range rows {
ids = append(ids, int64(row.(map[string]interface{})["id"].(float64)))
}
return ids
}
a, b := create("a"), create("b")
body := func(ids, expected []int64) string {
v, _ := json.Marshal(map[string]interface{}{"ids": ids, "expectedIds": expected})
return string(v)
}
check := func(payload string, code int) {
t.Helper()
out := orderRequest(t, router, token, resource, payload)
if out.Code != code {
t.Fatalf("want %d got %+v", code, out)
}
}
check(body([]int64{a, b}, []int64{b, a}), 0)
for _, payload := range []string{`{}`, `{"ids":null,"expectedIds":[]}`, `{"ids":[1.1],"expectedIds":[1]}`, body([]int64{a, a}, []int64{a, b}), body([]int64{a}, []int64{a, b}), body([]int64{a, b, 99999}, []int64{a, b}), body([]int64{a, 99999}, []int64{a, b}), body([]int64{a, b}, []int64{a, a})} {
check(payload, 400)
}
if resource == "user" {
check(body([]int64{1, a, b}, []int64{a, b}), 400)
check(body([]int64{1, a, b}, []int64{1, a, b}), 400)
}
check(body([]int64{b, a}, []int64{b, a}), 409)
c := create("c")
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
t.Fatalf("new item first: %v", got)
}
check(body([]int64{b, a}, []int64{a, b}), 409)
if resource == "user" {
if err := r.UpdateUserWithoutPassword(a, "edited", 10, 2, 0, 1, 1, 3, 0); err != nil {
t.Fatal(err)
}
} else {
if err := r.UpdateSpeedLimit(a, "edited", 200, 1, 0); err != nil {
t.Fatal(err)
}
}
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
t.Fatalf("edit changed order: %v", got)
}
if got := orderRequest(t, router, "", resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 401 {
t.Fatalf("missing auth: %+v", got)
}
normal, err := auth.GenerateToken(a, "edited", 1, "order-secret")
if err != nil {
t.Fatal(err)
}
// Speed-limit fixture needs a real ordinary account for auth lookup.
if resource == "speed-limit" {
uid, err := r.CreateUser("normal", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
if err != nil {
t.Fatal(err)
}
normal, err = auth.GenerateToken(uid, "normal", 1, "order-secret")
if err != nil {
t.Fatal(err)
}
}
if got := orderRequest(t, router, normal, resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 403 {
t.Fatalf("non-admin auth: %+v", got)
}
})
}
}
@@ -0,0 +1,367 @@
package contract_test
import (
"database/sql"
"errors"
"fmt"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"os"
"path/filepath"
"reflect"
"strconv"
"testing"
"time"
)
func TestDisplayOrderBackupAndRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "order.db")
r, err := repo.Open(path)
if err != nil {
t.Fatal(err)
}
a, err := r.CreateSpeedLimit("a", 100, 0, 1)
if err != nil {
t.Fatal(err)
}
b, err := r.CreateSpeedLimit("b", 200, 0, 1)
if err != nil {
t.Fatal(err)
}
if err := r.UpdateSpeedLimitDisplayOrder([]int64{a, b}, []int64{b, a}); err != nil {
t.Fatal(err)
}
check := func(r *repo.Repository) {
t.Helper()
rows, err := r.ListSpeedLimits()
if err != nil {
t.Fatal(err)
}
ids := []int64{}
for _, row := range rows {
ids = append(ids, row["id"].(int64))
}
if !reflect.DeepEqual(ids, []int64{a, b}) {
t.Fatalf("order: %v", ids)
}
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r, err = repo.Open(path)
if err != nil {
t.Fatal(err)
}
defer r.Close()
check(r)
backup, err := r.ExportPartial([]string{"speedLimits"})
if err != nil {
t.Fatal(err)
}
dest, err := repo.Open(filepath.Join(t.TempDir(), "restore.db"))
if err != nil {
t.Fatal(err)
}
defer dest.Close()
if _, err := dest.Import(backup, []string{"speedLimits"}); err != nil {
t.Fatal(err)
}
check(dest)
}
// Lifecycle and migration fixtures use DDL only to reproduce a pre-upgrade
// database and inject a write failure. All observations use public interfaces.
func TestDisplayOrderLifecycleDialects(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
open := orderTestDatabase(t, dialect)
r := open()
for _, resource := range []string{"user", "speed_limit"} {
t.Run(resource, func(t *testing.T) {
create := func(name string) int64 {
t.Helper()
var id int64
var err error
if resource == "user" {
id, err = r.CreateUser(name, security.MD5("test-password"), 1, 0, 123, 1, 4, 1, 5, 0)
} else {
id, err = r.CreateSpeedLimit(name, 123, 0, 1)
}
if err != nil {
t.Fatal(err)
}
return id
}
list := func(db *repo.Repository) []int64 {
t.Helper()
var rows []map[string]interface{}
var err error
if resource == "user" {
rows, err = db.ListUsers()
} else {
rows, err = db.ListSpeedLimits()
}
if err != nil {
t.Fatal(err)
}
ids := []int64{}
for _, row := range rows {
ids = append(ids, row["id"].(int64))
}
return ids
}
save := func(db *repo.Repository, ids, expected []int64) error {
if resource == "user" {
return db.UpdateUserDisplayOrder(ids, expected)
}
return db.UpdateSpeedLimitDisplayOrder(ids, expected)
}
a, b, c := create("a"), create("b"), create("c")
// Drop the new column to reproduce a database last opened by the old release.
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP COLUMN display_order").Error; err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r = open()
if got := list(r); !reflect.DeepEqual(got, []int64{c, b, a}) {
t.Fatalf("legacy order %v", got)
}
if err := save(r, []int64{a, c, b}, []int64{c, b, a}); err != nil {
t.Fatal(err)
}
backup, err := r.ExportPartial([]string{"users", "speedLimits"})
if err != nil {
t.Fatal(err)
}
restore := orderTestDatabase(t, dialect)()
defer restore.Close()
types := []string{"speedLimits"}
if resource == "user" {
types = []string{"users"}
}
if _, err := restore.Import(backup, types); err != nil {
t.Fatal(err)
}
if got := list(restore); !reflect.DeepEqual(got, []int64{a, c, b}) {
t.Fatalf("new backup order: %v", got)
}
before, err := r.ExportPartial([]string{"users", "speedLimits"})
if err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r = open()
second := open()
defer second.Close()
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
t.Fatalf("cross-client/restart order %v", got)
}
// The next save fails on its second row; the first row must roll back.
if dialect == "sqlite" {
if err := r.DB().Exec(fmt.Sprintf("CREATE TRIGGER reject_order BEFORE UPDATE OF display_order ON \"%s\" WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'injected failure'); END", resource, a)).Error; err != nil {
t.Fatal(err)
}
} else {
if err := r.DB().Exec(fmt.Sprintf("ALTER TABLE \"%s\" ADD CONSTRAINT reject_order CHECK (id != %d OR display_order = 0)", resource, a)).Error; err != nil {
t.Fatal(err)
}
}
if err := save(r, []int64{b, a, c}, []int64{a, c, b}); err == nil {
t.Fatal("expected injected failure")
}
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
t.Fatalf("partial write: %v", got)
}
if dialect == "sqlite" {
if err := r.DB().Exec("DROP TRIGGER reject_order").Error; err != nil {
t.Fatal(err)
}
} else {
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP CONSTRAINT reject_order").Error; err != nil {
t.Fatal(err)
}
}
// Exactly one cross-client save against the same observed order may win.
start := make(chan struct{})
results := make(chan error, 2)
for _, db := range []*repo.Repository{r, second} {
go func(db *repo.Repository) { <-start; results <- save(db, []int64{b, a, c}, []int64{a, c, b}) }(db)
}
close(start)
e1, e2 := <-results, <-results
if !((e1 == nil && errors.Is(e2, repo.ErrDisplayOrderConflict)) || (e2 == nil && errors.Is(e1, repo.ErrDisplayOrderConflict))) {
t.Fatalf("concurrent saves: %v / %v", e1, e2)
}
// Reordering must not mutate any exported fields besides rank.
after, err := r.ExportPartial([]string{"users", "speedLimits"})
if err != nil {
t.Fatal(err)
}
strip := func(b *model.BackupData) {
b.ExportedAt = 0
for i := range b.Users {
b.Users[i].DisplayOrder = nil
}
for i := range b.SpeedLimits {
b.SpeedLimits[i].DisplayOrder = nil
}
}
strip(before)
strip(after)
if !reflect.DeepEqual(before, after) {
t.Fatal("reorder modified non-order backup fields")
}
// Old backups omit ranks and must not overwrite an existing saved order.
legacy := orderTestDatabase(t, dialect)()
defer legacy.Close()
if _, err := legacy.Import(after, types); err != nil {
t.Fatal(err)
}
if got := list(legacy); !reflect.DeepEqual(got, []int64{c, b, a}) {
t.Fatalf("new legacy records: %v", got)
}
if _, err := r.Import(after, types); err != nil {
t.Fatal(err)
}
if got := list(r); !reflect.DeepEqual(got, []int64{b, a, c}) {
t.Fatalf("old backup reset existing order: %v", got)
}
d := create("d")
if got := list(r); !reflect.DeepEqual(got, []int64{d, b, a, c}) {
t.Fatalf("create after reorder: %v", got)
}
if err := save(second, []int64{a, b, c}, []int64{b, a, c}); !errors.Is(err, repo.ErrDisplayOrderConflict) {
t.Fatalf("stale membership: %v", err)
}
})
}
r.Close()
})
}
}
func orderTestDatabase(t *testing.T, dialect string) func() *repo.Repository {
t.Helper()
if dialect == "sqlite" {
path := filepath.Join(t.TempDir(), "lifecycle.db")
return func() *repo.Repository {
r, err := repo.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { r.Close() })
return r
}
}
dsn := os.Getenv("FLVX_POSTGRES_TEST_DSN")
if dsn == "" {
t.Skip("set FLVX_POSTGRES_TEST_DSN for isolated PostgreSQL contracts")
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
schema := "contract_display_order_" + strconv.FormatInt(time.Now().UnixNano(), 36)
if _, err := admin.Exec("CREATE SCHEMA \"" + schema + "\""); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Exec("DROP SCHEMA \"" + schema + "\" CASCADE"); admin.Close() })
scoped, err := withSearchPath(dsn, schema)
if err != nil {
t.Fatal(err)
}
return func() *repo.Repository {
r, err := repo.OpenPostgres(scoped)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { r.Close() })
return r
}
}
func TestDisplayOrderConcurrentCreate(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
for _, resource := range []string{"user", "speed-limit"} {
t.Run(resource, func(t *testing.T) {
open := orderTestDatabase(t, dialect)
r, other := open(), open()
create := func(db *repo.Repository, name string) (int64, error) {
if resource == "user" {
return db.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
}
return db.CreateSpeedLimit(name, 100, 0, 1)
}
save := func(ids, expected []int64) error {
if resource == "user" {
return r.UpdateUserDisplayOrder(ids, expected)
}
return r.UpdateSpeedLimitDisplayOrder(ids, expected)
}
a, err := create(r, "a")
if err != nil {
t.Fatal(err)
}
b, err := create(r, "b")
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
saved := make(chan error, 1)
type creation struct {
id int64
err error
}
created := make(chan creation, 1)
go func() { <-start; saved <- save([]int64{a, b}, []int64{b, a}) }()
go func() { <-start; id, err := create(other, "c"); created <- creation{id, err} }()
close(start)
saveErr, c := <-saved, <-created
if c.err != nil {
t.Fatal(c.err)
}
if saveErr != nil && !errors.Is(saveErr, repo.ErrDisplayOrderConflict) {
t.Fatalf("concurrent save: %v", saveErr)
}
var rows []map[string]interface{}
if resource == "user" {
rows, err = r.ListUsers()
} else {
rows, err = r.ListSpeedLimits()
}
if err != nil {
t.Fatal(err)
}
got := []int64{}
for _, row := range rows {
got = append(got, row["id"].(int64))
}
want := []int64{c.id, a, b}
if saveErr != nil {
want = []int64{c.id, b, a}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("concurrent create order %v want %v", got, want)
}
if resource == "user" {
err = r.DeleteUserCascade(c.id)
} else {
err = r.DeleteSpeedLimit(c.id)
}
if err != nil {
t.Fatal(err)
}
if err := save(got, got); !errors.Is(err, repo.ErrDisplayOrderConflict) {
t.Fatalf("deleted membership not rejected: %v", err)
}
})
}
})
}
}
@@ -440,13 +440,15 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper()
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
var id int64
if err := r.DB().Raw(`
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).Error; err != nil {
RETURNING id
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Row().Scan(&id); err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
return id
}
func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 {
@@ -0,0 +1,300 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func TestForwardOwnerNamesFollowAccountRenames(t *testing.T) {
for _, database := range []string{"sqlite", "postgres"} {
t.Run(database, func(t *testing.T) {
for _, mode := range []string{"admin-name-only", "admin-name-and-password", "self-name-and-password"} {
t.Run(mode, func(t *testing.T) {
const secret = "issue574-isolated-test"
open := ownerNamesRepository(t, database)
r := open()
router := httpserver.NewRouter(handler.New(r, secret), secret)
for _, u := range []model.User{
{ID: 9103, User: "before-name", Pwd: security.MD5("old-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
{ID: 9104, User: "other-user", Pwd: security.MD5("other-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
} {
if err := r.DB().Create(&u).Error; err != nil {
t.Fatal(err)
}
}
exp := time.Now().Add(time.Hour).UnixMilli()
f := model.Forward{ID: 9301, UserID: 9103, UserName: "before-name", Name: "existing-rule", TunnelID: 9201, RemoteAddr: "127.0.0.1:8080", Status: 1, InFlow: 123, OutFlow: 456}
for _, v := range []interface{}{&model.Tunnel{ID: 9201, Name: "isolated", Type: 1, Protocol: "tls", Status: 1, Flow: 1}, &model.UserTunnel{ID: 9401, UserID: 9103, TunnelID: 9201, Status: 1, Flow: 100, Num: 10, ExpTime: exp}, &f} {
if err := r.DB().Create(v).Error; err != nil {
t.Fatal(err)
}
}
token := func(id int64, name string, role int) string {
v, e := auth.GenerateTokenAt(id, name, role, secret, time.Now().Add(time.Second))
if e != nil {
t.Fatal(e)
}
return v
}
admin := token(1, "admin_user", 0)
oldToken, e := auth.GenerateTokenAt(9103, "before-name", 1, secret, time.Now().Add(-time.Second))
if e != nil {
t.Fatal(e)
}
call := func(path, tok string, body interface{}) (int, json.RawMessage) {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
q := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
q.Header.Set("Authorization", tok)
q.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, q)
var out struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
}
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
t.Fatal(e)
}
return out.Code, out.Data
}
path, tok := "/api/v1/user/update", admin
body := map[string]interface{}{"id": 9103, "user": "after-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}
if mode == "admin-name-and-password" {
body["pwd"] = "new-password"
}
if mode == "self-name-and-password" {
path = "/api/v1/user/updatePassword"
tok = oldToken
body = map[string]interface{}{"newUsername": "after-name", "currentPassword": "old-password", "newPassword": "new-password", "confirmPassword": "new-password"}
}
if code, _ := call(path, tok, body); code != 0 {
t.Fatalf("rename code=%d", code)
}
fresh := token(9103, "after-name", 1)
code, data := call("/api/v1/forward/list", admin, map[string]interface{}{})
if code != 0 {
t.Fatal(code)
}
var rows []map[string]interface{}
if err := json.Unmarshal(data, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0]["userName"] != "after-name" || rows[0]["userId"] != float64(9103) {
t.Fatalf("expected current owner name: %s", data)
}
if rows[0]["id"] != float64(9301) || rows[0]["inFlow"] != float64(123) || rows[0]["outFlow"] != float64(456) || rows[0]["status"] != float64(1) {
t.Fatalf("rule identity/traffic/status changed: %s", data)
}
if code, data = call("/api/v1/forward/list", fresh, map[string]interface{}{}); code != 0 {
t.Fatalf("owner denied: %d", code)
}
rows = nil
if err := json.Unmarshal(data, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0]["userName"] != "after-name" {
t.Fatalf("owner cannot see renamed rule: %s", data)
}
code, data = call("/api/v1/tunnel/delete-preview", admin, map[string]interface{}{"id": 9201})
if code != 0 {
t.Fatalf("preview code=%d", code)
}
var preview struct {
SampleForwards []struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
} `json:"sampleForwards"`
}
if err := json.Unmarshal(data, &preview); err != nil {
t.Fatal(err)
}
if len(preview.SampleForwards) != 1 || preview.SampleForwards[0].ID != 9301 || preview.SampleForwards[0].UserID != 9103 || preview.SampleForwards[0].UserName != "after-name" {
t.Fatalf("preview must show current owner: %s", data)
}
code, data = call("/api/v1/tunnel/batch-delete-preview", admin, map[string]interface{}{"ids": []int64{9201}})
if code != 0 {
t.Fatalf("batch preview code=%d", code)
}
var batch struct {
Items []struct {
SampleForwards []struct {
UserName string `json:"userName"`
} `json:"sampleForwards"`
} `json:"items"`
}
if err := json.Unmarshal(data, &batch); err != nil {
t.Fatal(err)
}
if len(batch.Items) != 1 || len(batch.Items[0].SampleForwards) != 1 || batch.Items[0].SampleForwards[0].UserName != "after-name" {
t.Fatalf("batch preview must show current owner: %s", data)
}
exported, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(exported.UserTunnels) != 1 || exported.UserTunnels[0].UserID != 9103 || exported.UserTunnels[0].TunnelID != 9201 || exported.UserTunnels[0].Flow != 100 || exported.UserTunnels[0].Status != 1 {
t.Fatalf("rename changed tunnel grant: %+v", exported.UserTunnels)
}
oldCode, _ := call("/api/v1/forward/list", oldToken, map[string]interface{}{})
wantOld := 0
if mode != "admin-name-only" {
wantOld = 401
}
if oldCode != wantOld {
t.Fatalf("old token=%d want %d", oldCode, wantOld)
}
// Re-use the abandoned username for another identity; it must not acquire rules.
if code, _ := call("/api/v1/user/update", admin, map[string]interface{}{"id": 9104, "user": "before-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}); code != 0 {
t.Fatalf("reuse old name: %d", code)
}
other := token(9104, "before-name", 1)
code, data = call("/api/v1/forward/list", other, map[string]interface{}{})
if code != 0 {
t.Fatal(code)
}
rows = nil
if err := json.Unmarshal(data, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("old name gained rules: %s", data)
}
if code, _ := call("/api/v1/forward/reset-flow", other, map[string]interface{}{"id": 9301}); code == 0 {
t.Fatal("other user reset original rule")
}
if code, _ := call("/api/v1/forward/reset-flow", fresh, map[string]interface{}{"id": 9301}); code != 0 {
t.Fatalf("owner reset denied: %d", code)
}
})
}
})
}
}
// Each PostgreSQL test owns only its unique schema, never the shared test database.
func ownerNamesRepository(t *testing.T, database string) func() *repo.Repository {
t.Helper()
dsn := filepath.Join(t.TempDir(), "owners.db")
if database == "postgres" {
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
if baseDSN == "" {
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres owner-name contracts")
}
schema := "contract_owner_names_" + strconv.FormatInt(time.Now().UnixNano(), 36)
db, err := sql.Open("pgx", baseDSN)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
db.Close()
t.Fatal(err)
}
t.Cleanup(func() { _, _ = db.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`); _ = db.Close() })
dsn, err = withSearchPath(baseDSN, schema)
if err != nil {
t.Fatal(err)
}
}
return func() *repo.Repository {
t.Helper()
var r *repo.Repository
var err error
if database == "postgres" {
r, err = repo.OpenPostgres(dsn)
} else {
r, err = repo.Open(dsn)
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
return r
}
}
func TestForwardOwnerNamesSurviveLegacyBackupAndRestart(t *testing.T) {
for _, database := range []string{"sqlite", "postgres"} {
t.Run(database, func(t *testing.T) {
open := ownerNamesRepository(t, database)
r := open()
// A legacy backup stores the rule's original name, not the user's current one.
const legacy = `{"users":[{"id":9103,"user":"current-owner","pwd":"test-only-password","roleId":1,"status":1}],
"forwards":[
{"id":9301,"userId":9103,"userName":"former-owner","name":"historical-rule","tunnelId":9201,"inFlow":123,"outFlow":456,"status":1},
{"id":9302,"userId":9199,"userName":"missing-owner","name":"orphan-rule","tunnelId":9201,"status":1},
{"id":9303,"userId":9198,"userName":"","name":"unknown-owner-rule","tunnelId":9201,"status":1}]}`
var backup model.BackupData
if err := json.Unmarshal([]byte(legacy), &backup); err != nil {
t.Fatal(err)
}
if _, err := r.Import(&backup, []string{"users", "forwards"}); err != nil {
t.Fatal(err)
}
check := func(stage string) {
t.Helper()
rows, err := r.ListForwards()
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("%s: rows=%+v", stage, rows)
}
for i, want := range []string{"current-owner", "missing-owner", ""} {
if rows[i]["userName"] != want {
t.Fatalf("%s: row %d owner=%v want %q", stage, i, rows[i]["userName"], want)
}
}
if rows[0]["id"] != int64(9301) || rows[0]["userId"] != int64(9103) || rows[0]["inFlow"] != int64(123) || rows[0]["outFlow"] != int64(456) {
t.Fatalf("%s: rule changed: %+v", stage, rows[0])
}
previews, err := r.ListForwardsByTunnel(9201)
if err != nil {
t.Fatal(err)
}
if len(previews) != 3 || previews[0].UserName != "current-owner" || previews[1].UserName != "missing-owner" || previews[2].UserName != "" {
t.Fatalf("%s: preview owners=%+v", stage, previews)
}
exported, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(exported.Forwards) != 3 || exported.Forwards[0].UserName != "former-owner" {
t.Fatalf("%s: stored legacy snapshot was rewritten: %+v", stage, exported.Forwards)
}
}
check("import")
if err := r.Close(); err != nil {
t.Fatal(err)
}
r = open()
check("restart")
// A forwards-only restore must resolve against the existing user by ID.
if _, err := r.Import(&backup, []string{"forwards"}); err != nil {
t.Fatal(err)
}
check("partial restore")
})
}
}
@@ -0,0 +1,554 @@
package contract_test
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func TestPostgresBackupImportAllowsImmediateCreate(t *testing.T) {
source := ownerNamesRepository(t, "postgres")()
for _, name := range []string{"backup-one", "backup-two"} {
if _, err := source.CreateUser(name, security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil {
t.Fatal(err)
}
if _, err := source.CreateSpeedLimit(name, 100, 0, 1); err != nil {
t.Fatal(err)
}
}
backup, err := source.ExportAll()
if err != nil {
t.Fatal(err)
}
target := ownerNamesRepository(t, "postgres")()
if _, err := target.Import(backup, []string{"users", "speedLimits"}); err != nil {
t.Fatal(err)
}
if id, err := target.CreateUser("after-import", security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil || id <= 2 {
t.Errorf("create user immediately after backup import: id=%d err=%v", id, err)
}
if id, err := target.CreateSpeedLimit("after-import", 100, 0, 1); err != nil || id <= 2 {
t.Errorf("create speed limit immediately after backup import: id=%d err=%v", id, err)
}
}
func TestPostgresBackupImportPreservesAllocatedIDs(t *testing.T) {
for _, called := range []bool{false, true} {
t.Run(fmt.Sprintf("called=%t", called), func(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
// A deleted or rolled-back allocation may be higher than every row.
for _, table := range []string{"user", "speed_limit"} {
if err := r.DB().Exec(`SELECT setval(pg_get_serial_sequence(?, 'id'), 500, ?)`, `"`+table+`"`, called).Error; err != nil {
t.Fatal(err)
}
}
b := &model.BackupData{Users: []model.UserBackup{{ID: 1, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "restored"}}}
if _, err := r.Import(b, []string{"users", "speedLimits"}); err != nil {
t.Fatal(err)
}
want := int64(500)
if called {
want++
}
if id, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != want {
t.Fatalf("user id=%d, want %d; err=%v", id, want, err)
}
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != want {
t.Fatalf("speed limit id=%d, want %d; err=%v", id, want, err)
}
})
}
}
func TestPostgresBackupImportInvalidatesCachedIDs(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq CACHE 20`).Error; err != nil {
t.Fatal(err)
}
pool, err := r.DB().DB()
if err != nil {
t.Fatal(err)
}
cached, err := pool.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
defer cached.Close()
// Hold this backend out of the pool with unused cached IDs 2..20.
var allocated int64
if err := cached.QueryRowContext(context.Background(), `SELECT nextval('speed_limit_id_seq')`).Scan(&allocated); err != nil {
t.Fatal(err)
}
if allocated != 1 {
t.Fatal(allocated)
}
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 2, Name: "restored"}}}
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
t.Fatal(err)
}
// Return only the cached backend to the pool, so the public create must use it.
pool.SetMaxIdleConns(0)
pool.SetMaxIdleConns(1)
if err := cached.Close(); err != nil {
t.Fatal(err)
}
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id <= 20 {
t.Fatalf("cached ID reused: id=%d err=%v", id, err)
}
}
func TestPostgresBackupImportRollsBackSequenceOnCommitFailure(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
// Deferred validation fails after import has already advanced the sequence.
for _, query := range []string{
`CREATE FUNCTION reject_import_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'reject imported row at commit'; END $$`,
`CREATE CONSTRAINT TRIGGER reject_import_commit AFTER INSERT ON speed_limit DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name = 'fail-at-commit') EXECUTE FUNCTION reject_import_commit()`,
} {
if err := r.DB().Exec(query).Error; err != nil {
t.Fatal(err)
}
}
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "fail-at-commit"}}}
if _, err := r.Import(b, []string{"speedLimits"}); err == nil || !strings.Contains(err.Error(), "reject imported row at commit") {
t.Fatalf("expected commit failure, got %v", err)
}
exported, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(exported.SpeedLimits) != 0 {
t.Fatal("failed import persisted rows")
}
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
t.Fatalf("failed import changed next allocation: id=%d err=%v", id, err)
}
}
func TestPostgresBackupImportPreservesSequenceIncrement(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq INCREMENT BY 10`).Error; err != nil {
t.Fatal(err)
}
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 25, Name: "restored"}}}
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
t.Fatal(err)
}
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 31 {
t.Fatalf("changed ID progression: id=%d, want 31; err=%v", id, err)
}
}
func TestPostgresBackupImportAdvancesEveryExplicitIDTable(t *testing.T) {
source := ownerNamesRepository(t, "postgres")()
types := []string{"users", "speedLimits", "nodes", "tunnels", "forwards", "userTunnels", "tunnelGroups", "userGroups", "permissions", "configs"}
fixture := &model.BackupData{
Users: []model.UserBackup{{ID: 1, User: "old", Pwd: "$2-test-fixture", RoleID: 1}},
SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "old"}},
Nodes: []model.NodeBackup{{ID: 1, Name: "old", Secret: "old-node"}},
Tunnels: []model.TunnelBackup{{ID: 1, Name: "old", ChainTunnels: []model.ChainTunnelBackup{{ID: 1, TunnelID: 1, NodeID: 1, ChainType: "1"}}}},
Forwards: []model.ForwardBackup{{ID: 1, Name: "old", UserID: 1, TunnelID: 1}},
UserTunnels: []model.UserTunnelBackup{{ID: 1, UserID: 1, TunnelID: 1}},
TunnelGroups: []model.TunnelGroupBackup{{ID: 1, Name: "old"}},
UserGroups: []model.UserGroupBackup{{ID: 1, Name: "old"}},
Permissions: []model.PermissionBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, Grants: []model.PermissionGrantBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, UserTunnelID: 1}}}},
}
if _, err := source.Import(fixture, types); err != nil {
t.Fatal(err)
}
backup, err := source.ExportAll()
if err != nil {
t.Fatal(err)
}
r := ownerNamesRepository(t, "postgres")()
if _, err := r.Import(backup, types); err != nil {
t.Fatal(err)
}
// Use the normal public creation methods, including the nested child tables.
for _, create := range []struct {
name string
run func() error
}{
{"user", func() error { _, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); return err }},
{"speed limit", func() error { _, err := r.CreateSpeedLimit("new", 100, 0, 1); return err }},
{"node", func() error {
return r.CreateRemoteNode("new", "new-node", "127.0.0.1", "10000-20000", 0, 1, 0, "", "", "")
}},
{"tunnel", func() error { _, err := r.CreateTunnelTx(r.DB(), "new", 1, 1, 0, 0, 1, nil, 0, "", "", 0); return err }},
{"chain tunnel", func() error { return r.CreateChainTunnelTx(r.DB(), 1, "1", 2, sql.NullInt64{}, "", 1, "tls", "") }},
{"forward", func() error {
_, err := r.CreateForwardTx(1, "old", "new", 1, "127.0.0.1:80", "", 0, 0, nil, 0, "", nil, 0, 0, nil, 0, 0, 0)
return err
}},
{"user tunnel", func() error { return r.InsertUserTunnel(2, 1, nil, 0, 0, 0, 0, 1) }},
{"tunnel group", func() error { return r.GroupCreate("tunnel_group", "new", 1, 0) }},
{"user group", func() error { return r.GroupCreate("user_group", "new", 1, 0) }},
{"permission", func() error { return r.InsertGroupPermission(2, 2, 0) }},
{"permission grant", func() error { r.InsertGroupPermissionGrant(1, 1, 2, 1, 0); return nil }},
} {
t.Run(create.name, func(t *testing.T) {
if err := create.run(); err != nil {
t.Fatal(err)
}
})
}
got, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
ids := map[string][]int64{}
for _, v := range got.Users {
ids["user"] = append(ids["user"], v.ID)
}
for _, v := range got.SpeedLimits {
ids["speed limit"] = append(ids["speed limit"], v.ID)
}
for _, v := range got.Nodes {
ids["node"] = append(ids["node"], v.ID)
}
for _, v := range got.Tunnels {
ids["tunnel"] = append(ids["tunnel"], v.ID)
for _, child := range v.ChainTunnels {
ids["chain tunnel"] = append(ids["chain tunnel"], child.ID)
}
}
for _, v := range got.Forwards {
ids["forward"] = append(ids["forward"], v.ID)
}
for _, v := range got.UserTunnels {
ids["user tunnel"] = append(ids["user tunnel"], v.ID)
}
for _, v := range got.TunnelGroups {
ids["tunnel group"] = append(ids["tunnel group"], v.ID)
}
for _, v := range got.UserGroups {
ids["user group"] = append(ids["user group"], v.ID)
}
for _, v := range got.Permissions {
ids["permission"] = append(ids["permission"], v.ID)
for _, child := range v.Grants {
ids["permission grant"] = append(ids["permission grant"], child.ID)
}
}
if len(ids) != 11 {
t.Fatalf("missing tables in export: %v", ids)
}
for name, values := range ids {
slices.Sort(values)
if !slices.Equal(values, []int64{1, 2}) {
t.Errorf("%s: expected old and new IDs [1 2], got %v", name, values)
}
}
}
func TestPostgresBackupImportKeepsExistingRowsAndEmptySelections(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
// Fixture simulates an existing explicit-ID row with an unsynchronized sequence.
if err := r.DB().Create(&model.SpeedLimit{ID: 500, Name: "existing"}).Error; err != nil {
t.Fatal(err)
}
old := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
if _, err := r.Import(old, []string{"speedLimits"}); err != nil {
t.Fatal(err)
}
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 501 {
t.Fatalf("existing maximum ignored: id=%d err=%v", id, err)
}
for _, b := range []*model.BackupData{{}, {Users: []model.UserBackup{{ID: 900, User: "not-selected", Pwd: "$2-test-fixture"}}}} {
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
t.Fatal(err)
}
}
if id, err := r.CreateUser("first-user", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
t.Fatalf("unselected sequence changed: id=%d err=%v", id, err)
}
if id, err := r.CreateSpeedLimit("after-empty", 100, 0, 1); err != nil || id != 502 {
t.Fatalf("empty import changed allocation: id=%d err=%v", id, err)
}
got, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(got.Users) != 2 || len(got.SpeedLimits) != 4 || got.SpeedLimits[1].Name != "existing" {
t.Fatalf("existing or unselected data changed: %+v", got)
}
}
func TestPostgresBackupImportResolvesLegacySequenceAndSearchPath(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
pool, err := r.DB().DB()
if err != nil {
t.Fatal(err)
}
pool.SetMaxOpenConns(1)
var schema string
if err := r.DB().Raw(`SELECT current_schema()`).Scan(&schema).Error; err != nil {
t.Fatal(err)
}
prefix := "Empty-Prefix-" + strconv.FormatInt(time.Now().UnixNano(), 36)
if err := r.DB().Exec(`CREATE SCHEMA "` + prefix + `"`).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.DB().Exec(`DROP SCHEMA "` + prefix + `" CASCADE`).Error })
// The historical default uses a custom, unowned sequence; the original
// owned serial sequence still exists but is no longer used by INSERT.
for _, query := range []string{
`CREATE SEQUENCE "Custom Limit Sequence"`,
`ALTER TABLE speed_limit ALTER COLUMN id SET DEFAULT nextval('"Custom Limit Sequence"'::regclass)`,
`SET search_path TO "` + prefix + `", "` + schema + `"`,
} {
if err := r.DB().Exec(query).Error; err != nil {
t.Fatal(err)
}
}
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
t.Fatal(err)
}
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 2 {
t.Fatalf("wrong sequence or schema: id=%d err=%v", id, err)
}
}
func waitForBackupDBCondition(t *testing.T, r *repo.Repository, query string, args ...interface{}) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var ready bool
if err := r.DB().Raw(query, args...).Scan(&ready).Error; err != nil {
t.Fatal(err)
}
if ready {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("database barrier was not reached")
}
func backupImportBarrier(t *testing.T, r *repo.Repository, table, timing, event, condition string) (*sql.Tx, int64) {
t.Helper()
pool, err := r.DB().DB()
if err != nil {
t.Fatal(err)
}
barrier, err := pool.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = barrier.Rollback() })
key := time.Now().UnixNano() & 0x3fffffff
if _, err := barrier.Exec(`SELECT pg_advisory_xact_lock($1)`, key); err != nil {
t.Fatal(err)
}
guardEnd := ""
if condition != "" {
guardEnd = "END IF;"
}
function := fmt.Sprintf(`CREATE FUNCTION pause_backup_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN %s PERFORM pg_advisory_xact_lock(%d); %s RETURN NEW; END $$`, condition, key, guardEnd)
if err := r.DB().Exec(function).Error; err != nil {
t.Fatal(err)
}
trigger := fmt.Sprintf(`CREATE TRIGGER pause_backup_test %s %s ON %s FOR EACH ROW EXECUTE FUNCTION pause_backup_test()`, timing, event, table)
if err := r.DB().Exec(trigger).Error; err != nil {
t.Fatal(err)
}
return barrier, key
}
func TestPostgresBackupImportSerializesConcurrentCreatesAndImports(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
barrier, key := backupImportBarrier(t, r, "speed_limit", "BEFORE", "INSERT", "IF NEW.name = 'hold-import' THEN")
first := make(chan error, 1)
go func() {
_, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "hold-import"}}}, []string{"speedLimits"})
first <- err
}()
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
created := make(chan error, 1)
go func() {
id, err := r.CreateSpeedLimit("concurrent-create", 100, 0, 1)
if err == nil && id <= 100 {
err = fmt.Errorf("reused imported ID: %d", id)
}
created <- err
}()
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='speed_limit'::regclass AND NOT granted)`)
second := make(chan error, 1)
go func() {
result, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 200, Name: "second-import"}}}, []string{"speedLimits"})
if err == nil && result.SpeedLimitsImported != 1 {
err = fmt.Errorf("wrong retry count: %+v", result)
}
second <- err
}()
if err := barrier.Commit(); err != nil {
t.Fatal(err)
}
for name, done := range map[string]<-chan error{"first import": first, "create": created, "second import": second} {
select {
case err := <-done:
if err != nil {
t.Errorf("%s: %v", name, err)
}
case <-time.After(3 * time.Second):
t.Fatalf("%s blocked", name)
}
}
if id, err := r.CreateSpeedLimit("after-both", 100, 0, 1); err != nil || id <= 200 {
t.Fatalf("second import sequence not advanced: id=%d err=%v", id, err)
}
got, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(got.SpeedLimits) != 4 {
t.Fatalf("lost concurrent rows: %+v", got.SpeedLimits)
}
}
func TestPostgresBackupImportAvoidsCascadeDeleteDeadlock(t *testing.T) {
for _, mode := range []string{"retry-success", "busy-bounded"} {
t.Run(mode, func(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
user, err := r.CreateUser("delete-me", "hash", 1, 0, 0, 0, 0, 1, 0, 0)
if err != nil {
t.Fatal(err)
}
forward, err := r.CreateForwardTx(user, "delete-me", "old", 1, "127.0.0.1:80", "", 0, 0, []int64{1}, 12345, "", nil, 0, 0, nil, 0, 0, 0)
if err != nil {
t.Fatal(err)
}
// Pause a real public cascade after deleting a child row, before it
// reaches forward/user. Import must not retain partial parent locks.
barrier, key := backupImportBarrier(t, r, "forward_port", "AFTER", "DELETE", "")
deleted := make(chan error, 1)
go func() { deleted <- r.DeleteUserCascade(user) }()
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
emptyPorts := []model.ForwardPortBackup{}
backup := &model.BackupData{
Users: []model.UserBackup{{ID: 100, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}},
Forwards: []model.ForwardBackup{{ID: forward, UserID: 100, Name: "restored", ForwardPorts: &emptyPorts}},
}
imported := make(chan error, 1)
runImport := func() {
result, err := r.Import(backup, []string{"users", "forwards"})
if err == nil && (result.UsersImported != 1 || result.ForwardsImported != 1) {
err = fmt.Errorf("wrong retry count: %+v", result)
}
imported <- err
}
go runImport()
if mode == "busy-bounded" {
select {
case err := <-imported:
if err == nil || !strings.Contains(err.Error(), "database is busy") {
t.Fatalf("expected bounded busy error, got %v", err)
}
case <-time.After(3 * time.Second):
_ = barrier.Rollback()
t.Fatal("import waited while holding partial table locks")
}
} else {
// The held child lock makes every attempt fail before writes.
// Let several bounded attempts encounter it before release.
time.Sleep(75 * time.Millisecond)
}
if err := barrier.Commit(); err != nil {
t.Fatal(err)
}
select {
case err := <-deleted:
if err != nil {
t.Fatalf("cascade: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("cascade deadlocked")
}
if mode == "busy-bounded" {
go runImport()
}
select {
case err := <-imported:
if err != nil {
t.Fatalf("import: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("import deadlocked")
}
if id, err := r.CreateUser("after", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id <= 100 {
t.Fatalf("create after concurrent cascade: id=%d err=%v", id, err)
}
})
}
}
func TestPostgresBackupImportRollsBackEarlierSequenceWhenLaterSequenceFails(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq MAXVALUE 10`).Error; err != nil {
t.Fatal(err)
}
backup := &model.BackupData{
Users: []model.UserBackup{{ID: 100, User: "rollback-user", Pwd: "$2-test-fixture", RoleID: 1}},
SpeedLimits: []model.SpeedLimitBackup{{ID: 10, Name: "exhausted"}},
}
// User sequence advances first. The exhausted limit sequence then fails.
if _, err := r.Import(backup, []string{"users", "speedLimits"}); err == nil || !strings.Contains(err.Error(), "sequence exhausted") {
t.Fatalf("expected sequence exhaustion: %v", err)
}
if id, err := r.CreateUser("after-failure", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
t.Fatalf("earlier sequence failed to roll back: id=%d err=%v", id, err)
}
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
t.Fatalf("later sequence changed: id=%d err=%v", id, err)
}
got, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(got.Users) != 2 || len(got.SpeedLimits) != 1 {
t.Fatalf("partial import committed: %+v", got)
}
}
func TestPostgresBackupImportAPIAllowsImmediateCreate(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
const secret = "backup-sequence-contract"
router := httpserver.NewRouter(handler.New(r, secret), secret)
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatal(err)
}
post := func(path, body string) {
t.Helper()
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
request.Header.Set("Authorization", token)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assertCode(t, response, 0)
}
post("/api/v1/backup/import", `{"version":"1.0","types":["users","speedLimits"],"users":[{"id":2,"user":"restored","pwd":"$2-test-fixture","roleId":1,"status":1}],"speedLimits":[{"id":1,"name":"restored","speed":100,"status":1}]}`)
post("/api/v1/user/create", `{"user":"after-import","pwd":"test-only-password","status":1}`)
post("/api/v1/speed-limit/create", `{"name":"after-import","speed":200}`)
got, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(got.Users) != 3 || got.Users[2].ID != 3 || len(got.SpeedLimits) != 2 || got.SpeedLimits[1].ID != 2 {
t.Fatalf("API create did not retain restored records: %+v", got)
}
}
@@ -11,11 +11,24 @@ import (
"time"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/repo"
)
func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
router, r := setupContractRouter(t, "relay-auth-contract")
assertNewRelayTunnelEndpointAuthentication(t, router, r)
}
func TestPostgresNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
r := ownerNamesRepository(t, "postgres")()
router := httpserver.NewRouter(handler.New(r, "relay-auth-contract"), "relay-auth-contract")
assertNewRelayTunnelEndpointAuthentication(t, router, r)
}
func assertNewRelayTunnelEndpointAuthentication(t *testing.T, router http.Handler, r *repo.Repository) {
t.Helper()
server := httptest.NewServer(router)
defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
@@ -0,0 +1,73 @@
package contract_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestSystemUpgradeStatusRequiresAdminAndReturnsLocalState(t *testing.T) {
secret := "upgrade-status-contract-secret"
dir := t.TempDir()
t.Setenv("PANEL_DEPLOY_DIR", dir)
t.Setenv("FLUX_VERSION", "3.0.1-rc10")
// A status read with no job must not require Docker or contact GitHub.
t.Setenv("PATH", t.TempDir())
router, r := setupContractRouter(t, secret)
seedContractUser(t, r, 2, "normal_user", 1, 1)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatal(err)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, token string
want int
}{{"anonymous", "", 401}, {"normal user", userToken, 403}, {"admin", adminToken, 0}} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(`{}`))
req.Header.Set("Authorization", tc.token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
var out response.R
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Code != tc.want {
t.Fatalf("expected code %d, got %d: %s", tc.want, out.Code, out.Msg)
}
if tc.want == 0 {
data, ok := out.Data.(map[string]interface{})
if !ok || data["currentVersion"] != "3.0.1-rc10" || data["job"] != nil {
t.Fatalf("unexpected idle status: %#v", out.Data)
}
}
})
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 0 {
t.Fatalf("idle status should not mutate deployment: entries=%v err=%v", entries, err)
}
for _, body := range []string{`{"jobId":"../../.env"}`, `{"jobId":`, `{"jobId":123}`} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(body))
req.Header.Set("Authorization", adminToken)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
var out response.R
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Code == 0 || out.Data != nil || strings.Contains(out.Msg, dir) || strings.Contains(out.Msg, ".env") {
t.Fatalf("invalid request must fail without leaking filesystem detail: %#v", out)
}
}
}
@@ -0,0 +1,98 @@
package contract_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-backend/internal/auth"
)
func TestTunnelRedeployStreamContract(t *testing.T) {
const secret = "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(`{"ids":[999998,999999]}`))
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if !strings.HasPrefix(res.Header().Get("Content-Type"), "application/x-ndjson") {
t.Fatalf("expected streamed progress, got %s: %s", res.Header().Get("Content-Type"), res.Body.String())
}
decoder := json.NewDecoder(res.Body)
for _, want := range []string{"start", "processing", "item", "processing", "item", "done"} {
var event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := decoder.Decode(&event); err != nil {
t.Fatal(err)
}
if event.Type != want {
t.Fatalf("got %s, want %s", event.Type, want)
}
if want == "item" {
var data struct{ Result map[string]interface{} }
if err := json.Unmarshal(event.Data, &data); err != nil {
t.Fatal(err)
}
if _, ok := data.Result["name"].(string); !ok {
t.Fatalf("item name must be a string, including missing tunnels: %s", event.Data)
}
}
if want == "done" {
var data struct {
Progress struct{ Total, Completed, Success, Failed int }
Result struct {
SuccessCount, FailCount int
Failures []struct {
ID int64
Reason string
}
}
}
if err := json.Unmarshal(event.Data, &data); err != nil {
t.Fatal(err)
}
if data.Progress.Total != 2 || data.Progress.Completed != 2 || data.Progress.Failed != 2 || data.Progress.Success != 0 || data.Result.FailCount != 2 || len(data.Result.Failures) != 2 {
t.Fatalf("incorrect completion: %+v", data)
}
if data.Result.Failures[0].Reason == "" {
t.Fatal("missing failure detail")
}
}
}
if !res.Flushed {
t.Fatal("stream did not flush")
}
}
func TestTunnelRedeployStreamAuthorizationContract(t *testing.T) {
const secret = "contract-jwt-secret"
router, repository := setupContractRouter(t, secret)
seedLegacyUser(t, repository, 9111, "stream-normal-user", "password")
token, err := auth.GenerateToken(9111, "stream-normal-user", 1, secret)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, token string
code int
}{{"anonymous", "", 401}, {"non-admin", token, 403}} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(`{"ids":[999999]}`))
req.Header.Set("Authorization", tc.token)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, tc.code)
if strings.Contains(res.Body.String(), `"type":"start"`) {
t.Fatal("unauthorized request started stream")
}
})
}
}
+139
View File
@@ -0,0 +1,139 @@
package socket
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type agentServiceManager struct{ kind, control, runner string }
// Alpine must never select a systemctl compatibility shim. Elsewhere prefer
// an active systemd instance, matching the installer, then OpenRC.
func detectAgentServiceManager(alpine, systemd bool, lookup func(string) (string, error)) (agentServiceManager, error) {
if !alpine && systemd {
control, err := lookup("systemctl")
if err != nil {
return agentServiceManager{}, fmt.Errorf("systemctl 不可用: %w", err)
}
runner, err := lookup("systemd-run")
if err != nil {
return agentServiceManager{}, fmt.Errorf("systemd-run 不可用: %w", err)
}
return agentServiceManager{kind: "systemd", control: control, runner: runner}, nil
}
control, err := lookup("rc-service")
if err != nil {
return agentServiceManager{}, fmt.Errorf("未检测到受支持的服务管理器 (systemd/OpenRC): %w", err)
}
return agentServiceManager{kind: "openrc", control: control}, nil
}
func (w *WebSocketReporter) agentRestartLauncher(cleanLegacy bool) (func(string, string) error, error) {
// Existing per-reporter seam is also used by checksum-only tests.
if w.startUpgradeRestart != nil {
return w.startUpgradeRestart, nil
}
detect := w.detectAgentManager
if detect == nil {
detect = func() (agentServiceManager, error) {
_, alpineErr := os.Stat("/etc/alpine-release")
_, systemdErr := os.Stat("/run/systemd/system")
return detectAgentServiceManager(alpineErr == nil, systemdErr == nil, exec.LookPath)
}
}
manager, err := detect()
if err != nil {
return nil, err
}
if manager.kind == "openrc" {
if err := checkAgentDetach(); err != nil {
return nil, err
}
}
return func(staged, binary string) error {
script := buildManagedAgentRestartScript(manager, staged, binary, cleanLegacy)
if manager.kind == "systemd" {
// Wait only for submission, never for helper completion. A separate unit is
// essential: systemctl stop kills every process in the agent's cgroup.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, manager.runner, "--quiet", "/bin/sh", "-c", script).CombinedOutput()
if err != nil {
return fmt.Errorf("提交重启任务失败: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
return startDetachedAgentHelper(script)
}, nil
}
func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" }
func buildManagedAgentRestartScript(manager agentServiceManager, staged, binary string, cleanLegacy bool) string {
control := shellQuote(manager.control)
stop, start := control+" stop flux_agent", control+" start flux_agent"
if manager.kind == "openrc" {
stop, start = control+" flux_agent stop", control+" flux_agent start"
}
legacy := ""
if manager.kind == "systemd" && cleanLegacy {
legacy = `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 (` + control + ` stop gost 2>/dev/null || true); (` + control + ` disable gost 2>/dev/null || true); rm -f /usr/local/bin/gost /etc/gost/gost "$legacy_service"; (` + control + ` daemon-reload 2>/dev/null || true); fi; `
}
// The caller owns staging until launch. Afterwards the helper always removes
// it, including stop/replace failures. Never truncate the running executable.
return "trap " + shellQuote("rm -f -- "+shellQuote(staged)) + " EXIT; sleep 1; " + stop + " || exit $?; " + legacy +
"if [ ! -d " + shellQuote(binary) + " ] && mv -f -- " + shellQuote(staged) + " " + shellQuote(binary) + "; then " + start + "; else status=$?; " + start + "; exit \"$status\"; fi"
}
// Copy to a unique file on the destination filesystem, then let the caller
// atomically rename. Failure never truncates a previous usable backup.
func stageAgentCopy(source, destination string) (path string, err error) {
in, err := os.Open(source)
if err != nil {
return "", err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("不是普通文件: %s", source)
}
out, err := os.CreateTemp(filepath.Dir(destination), ".flux-agent-*")
if err != nil {
return "", err
}
path = out.Name()
defer func() {
out.Close()
if err != nil {
os.Remove(path)
}
}()
if _, err = io.Copy(out, in); err != nil {
return path, err
}
if err = out.Chmod(0755); err != nil {
return path, err
}
if err = out.Close(); err != nil {
return path, err
}
return path, nil
}
func backupAgentBinary(binary string) error {
staged, err := stageAgentCopy(binary, binary+".old")
if err != nil {
return err
}
defer os.Remove(staged)
return os.Rename(staged, binary+".old")
}
+20
View File
@@ -0,0 +1,20 @@
package socket
import (
"os/exec"
"syscall"
)
func checkAgentDetach() error { return nil }
func startDetachedAgentHelper(script string) error {
cmd := exec.Command("/bin/sh", "-c", script)
// The installed OpenRC service stops the agent PID. A separate session also
// survives a process-group stop. No inherited stdio or parent-death signal.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
}
@@ -0,0 +1,132 @@
package socket
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// This subprocess stands in for the running agent. The rc-service double kills
// its entire process group, not merely a fake PID, while the real helper runs.
func TestAgentDetachedParent(t *testing.T) {
dir := os.Getenv("FLVX_TEST_DETACH_ROOT")
if dir == "" {
t.Skip("subprocess helper")
}
binary := filepath.Join(dir, "agent")
control := filepath.Join(dir, "rc-service")
writeAgentFixture(t, control, "#!/bin/sh\nif [ \"$2\" = stop ]; then kill -TERM -"+strconv.Itoa(os.Getpid())+"; else echo started > "+shellQuote(filepath.Join(dir, "started"))+"; fi\n")
reporter := &WebSocketReporter{upgradeBinaryPath: binary, detectAgentManager: func() (agentServiceManager, error) { return agentServiceManager{kind: "openrc", control: control}, nil }}
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
if !response.Success {
t.Fatal(response.Message)
}
time.Sleep(15 * time.Second)
t.Fatal("original agent was not stopped")
}
func TestOpenRCHelperSurvivesAgentProcessGroupStop(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "agent")
writeAgentFixture(t, binary, "current")
writeAgentFixture(t, binary+".old", "backup")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAgentDetachedParent$", "-test.v")
cmd.Env = append(os.Environ(), "FLVX_TEST_DETACH_ROOT="+dir)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
output, err := cmd.CombinedOutput()
if err == nil || ctx.Err() != nil {
t.Fatalf("parent termination = %v, timeout=%v, %s", err, ctx.Err(), output)
}
exit, ok := err.(*exec.ExitError)
if !ok || exit.Sys().(syscall.WaitStatus).Signal() != syscall.SIGTERM {
t.Fatalf("parent did not receive SIGTERM: %v %s", err, output)
}
waitAgentFixture(t, filepath.Join(dir, "started"), "started\n")
if readAgentFixture(t, binary) != "backup" || readAgentFixture(t, binary+".old") != "backup" {
t.Fatal("rollback failed after parent termination")
}
fmt.Println("detached OpenRC helper survived original agent process-group SIGTERM")
}
// Run only in an explicitly opted-in disposable Alpine container. The docker
// invocation mounts only the test binary, never a host /bin directory. Removing
// the container's /bin/sh symlink forces the real detached cmd.Start to fail.
func TestOpenRCCommandsCleanStageOnHelperExecFailure(t *testing.T) {
if os.Getenv("FLVX_TEST_DISPOSABLE_ALPINE_SHELL_FAILURE") != "1" {
t.Skip("requires explicit disposable Alpine container opt-in")
}
for _, path := range []string{"/etc/alpine-release", "/.dockerenv"} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("refusing shell mutation outside disposable Alpine: %s: %v", path, err)
}
}
info, err := os.Lstat("/bin/sh")
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("refusing to rename a non-symlink /bin/sh: %v", err)
}
saved := "/bin/.flvx-test-sh-" + strconv.Itoa(os.Getpid())
if _, err := os.Lstat(saved); !os.IsNotExist(err) {
t.Fatalf("refusing to overwrite shell backup: %v", err)
}
if err := os.Rename("/bin/sh", saved); err != nil {
t.Fatal(err)
}
defer func() {
if err := os.Rename(saved, "/bin/sh"); err != nil {
t.Errorf("restore container shell: %v", err)
}
}()
for _, operation := range []string{"UpgradeAgent", "RollbackAgent"} {
t.Run(operation, func(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "agent")
writeAgentFixture(t, binary, "current")
writeAgentFixture(t, binary+".old", "prior backup")
writeAgentFixture(t, filepath.Join(dir, "rc-service"), "#!/bin/sh\nexit 0\n")
t.Setenv("PATH", dir)
// No manager or launcher injection: Alpine detection and exec.LookPath are
// production code too. Only the installation path points to an isolated dir.
reporter := &WebSocketReporter{upgradeBinaryPath: binary}
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/checksum" {
fmt.Fprint(w, abcSHA256)
} else {
fmt.Fprint(w, "abc")
}
}))
defer fixture.Close()
response := routeAgentOperation(t, reporter, operation, map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
if response.Success || !strings.Contains(response.Message, "/bin/sh") || !strings.Contains(response.Message, "no such file") {
t.Fatalf("expected real helper exec failure, got %+v", response)
}
if readAgentFixture(t, binary) != "current" {
t.Fatal("live binary changed")
}
backup := "prior backup"
if operation == "UpgradeAgent" {
backup = "current"
}
if readAgentFixture(t, binary+".old") != backup {
t.Fatal("unexpected backup contents")
}
matches, err := filepath.Glob(filepath.Join(dir, ".flux-agent-*"))
if err != nil || len(matches) != 0 {
t.Fatalf("rollback staging residue: %v %v", matches, err)
}
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
t.Fatalf("upgrade staging residue: %v", err)
}
})
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package socket
import "fmt"
func checkAgentDetach() error { return fmt.Errorf("OpenRC Agent 重启仅支持 Linux") }
func startDetachedAgentHelper(string) error { return checkAgentDetach() }
+329
View File
@@ -0,0 +1,329 @@
package socket
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func routeAgentOperation(t *testing.T, reporter *WebSocketReporter, operation string, data interface{}) CommandResponse {
t.Helper()
peer := make(chan *websocket.Conn, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err == nil {
peer <- c
}
}))
defer server.Close()
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer c.Close()
reader := <-peer
defer reader.Close()
reporter.ctx = context.Background()
reporter.conn = c
reporter.connected = true
reporter.routeCommand(CommandMessage{Type: operation, RequestId: "restart-request", Data: data})
reader.SetReadDeadline(time.Now().Add(3 * time.Second))
for {
var response CommandResponse
if err := reader.ReadJSON(&response); err != nil {
t.Fatal(err)
}
if response.Type == operation+"Response" {
if response.RequestId != "restart-request" {
t.Fatalf("request id lost: %s", response.RequestId)
}
return response
}
}
}
func TestRollbackAgentStagesBackupBeforeRestart(t *testing.T) {
binary := filepath.Join(t.TempDir(), "flux_agent")
for path, body := range map[string]string{binary: "current", binary + ".old": "backup"} {
if err := os.WriteFile(path, []byte(body), 0755); err != nil {
t.Fatal(err)
}
}
called := false
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(staged, dest string) error {
called = true
body, err := os.ReadFile(staged)
if err != nil || string(body) != "backup" || staged == binary+".old" || dest != binary {
t.Fatalf("bad rollback staging: %s %s %q %v", staged, dest, body, err)
}
return os.Rename(staged, dest)
}}
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
if !response.Success || !called {
t.Fatalf("rollback failed: called=%v %s", called, response.Message)
}
body, err := os.ReadFile(binary + ".old")
if err != nil || string(body) != "backup" {
t.Fatalf("backup consumed: %q %v", body, err)
}
}
func TestAgentServiceManagerDetection(t *testing.T) {
for _, tc := range []struct {
name string
alpine, systemd bool
tools string
want string
}{
{"alpine shim", true, true, "rc-service systemctl systemd-run", "openrc"},
{"alpine missing openrc", true, true, "systemctl systemd-run", ""},
{"systemd", false, true, "rc-service systemctl systemd-run", "systemd"},
{"missing runner", false, true, "systemctl", ""},
{"openrc fallback", false, false, "rc-service", "openrc"},
{"unmanaged", false, false, "systemctl systemd-run", ""},
} {
t.Run(tc.name, func(t *testing.T) {
manager, err := detectAgentServiceManager(tc.alpine, tc.systemd, func(name string) (string, error) {
if strings.Contains(" "+tc.tools+" ", " "+name+" ") {
return name, nil
}
return "", os.ErrNotExist
})
if manager.kind != tc.want || (err != nil) != (tc.want == "") {
t.Fatalf("manager=%+v err=%v", manager, err)
}
})
}
}
func writeAgentFixture(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0755); err != nil {
t.Fatal(err)
}
}
func readAgentFixture(t *testing.T, path string) string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(body)
}
func waitAgentFixture(t *testing.T, path, want string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
body, _ := os.ReadFile(path)
if string(body) == want {
return
}
time.Sleep(20 * time.Millisecond)
}
body, _ := os.ReadFile(path)
t.Fatalf("%s = %q, want %q", path, body, want)
}
func TestAgentCommandsManagerLaunch(t *testing.T) {
for _, operation := range []string{"UpgradeAgent", "RollbackAgent"} {
for _, mode := range []string{"systemd", "openrc", "missing", "rejected"} {
t.Run(operation+"/"+mode, func(t *testing.T) {
if mode == "openrc" && runtime.GOOS != "linux" {
t.Skip("detached launcher is Linux-only")
}
dir := t.TempDir()
binary := filepath.Join(dir, "flux 'agent")
log := filepath.Join(dir, "events")
writeAgentFixture(t, binary, "current")
writeAgentFixture(t, binary+".old", "backup")
control := filepath.Join(dir, "manager")
writeAgentFixture(t, control, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> "+shellQuote(log)+"\n")
runner := filepath.Join(dir, "runner")
writeAgentFixture(t, runner, "#!/bin/sh\nshift\n\"$@\" >/dev/null 2>&1 &\n")
if mode == "rejected" {
writeAgentFixture(t, runner, "#!/bin/sh\necho rejected >&2\nexit 9\n")
}
reporter := &WebSocketReporter{upgradeBinaryPath: binary, detectAgentManager: func() (agentServiceManager, error) {
return detectAgentServiceManager(mode == "openrc", mode != "missing", func(name string) (string, error) {
if mode == "missing" {
return "", os.ErrNotExist
}
if name == "systemd-run" {
return runner, nil
}
return control, nil
})
}}
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/checksum" {
fmt.Fprint(w, abcSHA256)
} else {
fmt.Fprint(w, "abc")
}
}))
defer fixture.Close()
response := routeAgentOperation(t, reporter, operation, map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
if mode == "missing" || mode == "rejected" {
if response.Success {
t.Fatal("accepted failed manager launch")
}
if readAgentFixture(t, binary) != "current" {
t.Fatal("live binary mutated")
}
if mode == "missing" && readAgentFixture(t, binary+".old") != "backup" {
t.Fatal("backup mutated before manager validation")
}
matches, _ := filepath.Glob(filepath.Join(dir, ".flux-agent-*"))
if len(matches) != 0 {
t.Fatalf("staging residue %v", matches)
}
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
t.Fatal("upgrade residue")
}
return
}
if !response.Success {
t.Fatal(response.Message)
}
want, backup := "abc", "current"
if operation == "RollbackAgent" {
want, backup = "backup", "backup"
}
waitAgentFixture(t, binary, want)
events := "stop flux_agent\nstart flux_agent\n"
if mode == "openrc" {
events = "flux_agent stop\nflux_agent start\n"
}
waitAgentFixture(t, log, events)
if readAgentFixture(t, binary+".old") != backup {
t.Fatal("wrong backup")
}
})
}
}
}
func TestAgentRestartScriptFailureOrdering(t *testing.T) {
for _, mode := range []string{"stop", "replace"} {
t.Run(mode, func(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "agent")
staged := filepath.Join(dir, "staged")
log := filepath.Join(dir, "events")
writeAgentFixture(t, binary, "current")
writeAgentFixture(t, staged, "replacement")
control := filepath.Join(dir, "rc-service")
script := "#!/bin/sh\necho \"$*\" >> " + shellQuote(log) + "\n"
if mode == "stop" {
script += "[ \"$2\" != stop ]\n"
}
writeAgentFixture(t, control, script)
if mode == "replace" {
writeAgentFixture(t, filepath.Join(dir, "mv"), "#!/bin/sh\nexit 23\n")
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
}
cmd := exec.Command("/bin/sh", "-c", buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: control}, staged, binary, true))
if err := cmd.Run(); err == nil {
t.Fatal("expected failed stop/replacement")
}
if readAgentFixture(t, binary) != "current" {
t.Fatal("changed binary after failure")
}
want := "flux_agent stop\n"
if mode == "replace" {
want += "flux_agent start\n"
}
if readAgentFixture(t, log) != want {
t.Fatalf("wrong sequence %q", readAgentFixture(t, log))
}
if _, err := os.Stat(staged); !os.IsNotExist(err) {
t.Fatal("staging residue")
}
})
}
}
func TestUpgradeAgentBackupFailurePreservesExistingBackup(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "agent")
writeAgentFixture(t, binary+".old", "prior")
called := false
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(string, string) error { called = true; return nil }}
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/checksum" {
fmt.Fprint(w, abcSHA256)
} else {
fmt.Fprint(w, "abc")
}
}))
defer fixture.Close()
response := routeAgentOperation(t, reporter, "UpgradeAgent", map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
if response.Success || called {
t.Fatal("upgrade accepted without backup")
}
if readAgentFixture(t, binary+".old") != "prior" {
t.Fatal("backup lost")
}
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
t.Fatal("staging residue")
}
}
func TestRollbackAgentRejectsDirectoryDestination(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "agent")
if err := os.Mkdir(binary, 0755); err != nil {
t.Fatal(err)
}
writeAgentFixture(t, binary+".old", "backup")
called := false
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(string, string) error { called = true; return nil }}
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
if response.Success || called {
t.Fatal("accepted directory destination")
}
if readAgentFixture(t, binary+".old") != "backup" {
t.Fatal("changed backup")
}
entries, err := os.ReadDir(binary)
if err != nil || len(entries) != 0 {
t.Fatalf("destination changed: %v %v", entries, err)
}
}
func TestAgentRestartScriptRejectsDirectoryDestination(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "agent")
staged := filepath.Join(dir, "stage")
if err := os.Mkdir(binary, 0755); err != nil {
t.Fatal(err)
}
writeAgentFixture(t, staged, "replacement")
script := buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: "/usr/bin/true"}, staged, binary, false)
if err := exec.Command("/bin/sh", "-c", script).Run(); err == nil {
t.Fatal("accepted directory destination")
}
entries, err := os.ReadDir(binary)
if err != nil || len(entries) != 0 {
t.Fatalf("binary moved inside directory: %v %v", entries, err)
}
}
func TestOpenRCRestartNeverCleansSystemdLegacyService(t *testing.T) {
script := buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: "rc-service"}, "staged", "binary", true)
for _, forbidden := range []string{"systemctl", "gost", "daemon-reload", "/etc/systemd"} {
if strings.Contains(script, forbidden) {
t.Fatalf("OpenRC helper contains %s: %s", forbidden, script)
}
}
}
+198
View File
@@ -0,0 +1,198 @@
package socket
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// All writes are confined to TempDir; the restart callback never starts a process.
func exerciseUpgrade(t *testing.T, checksum string, serveChecksum http.HandlerFunc, valid bool) {
t.Helper()
dir := t.TempDir()
binary := filepath.Join(dir, "flux_agent")
for path, body := range map[string]string{binary: "old binary", binary + ".old": "prior backup"} {
if err := os.WriteFile(path, []byte(body), 0700); err != nil {
t.Fatal(err)
}
}
downloads := 0
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/binary-"+runtime.GOARCH {
downloads++
fmt.Fprint(w, "abc")
return
}
if r.URL.Path != "/checksum-"+runtime.GOARCH {
t.Errorf("unexpected checksum path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
serveChecksum(w, r)
}))
defer fixture.Close()
if checksum == "fixture" {
checksum = fixture.URL + "/checksum-{ARCH}"
}
peer := make(chan *websocket.Conn, 1)
ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
peer <- c
}))
defer ws.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ws.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
reader := <-peer
defer reader.Close()
restarted := false
reporter := &WebSocketReporter{ctx: context.Background(), conn: conn, connected: true, upgradeBinaryPath: binary,
startUpgradeRestart: func(staged, destination string) error {
restarted = true
if destination != binary {
t.Errorf("wrong restart destination: %s", destination)
}
b, e := os.ReadFile(staged)
if e != nil || string(b) != "abc" {
t.Errorf("staged binary = %q, %v", b, e)
}
return nil
},
}
reporter.routeCommand(CommandMessage{Type: "UpgradeAgent", RequestId: "upgrade-test-request", Data: map[string]any{"downloadUrl": fixture.URL + "/binary-{ARCH}", "checksumUrl": checksum}})
reader.SetReadDeadline(time.Now().Add(2 * time.Second))
verified, installing := false, false
for {
var response CommandResponse
if err := reader.ReadJSON(&response); err != nil {
t.Fatal(err)
}
if response.Type == "UpgradeAgentResponse" {
if response.RequestId != "upgrade-test-request" {
t.Errorf("response request ID = %q", response.RequestId)
}
if response.Success != valid {
t.Errorf("success = %v, want %v: %s", response.Success, valid, response.Message)
}
break
}
if response.Type == "UpgradeProgress" {
data := response.Data.(map[string]interface{})
if data["stage"] == "verifying" && data["percent"] == float64(100) {
verified = true
}
if data["stage"] == "installing" {
installing = true
}
}
}
if restarted != valid || verified != valid || installing != valid {
t.Errorf("restart=%v verified=%v installing=%v, want %v", restarted, verified, installing, valid)
}
b, e := os.ReadFile(binary)
if e != nil || string(b) != "old binary" {
t.Errorf("old binary changed: %q %v", b, e)
}
if !valid {
b, e = os.ReadFile(binary + ".old")
if e != nil || string(b) != "prior backup" {
t.Errorf("backup changed: %q %v", b, e)
}
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
t.Errorf("staged binary remains: %v", err)
}
}
if strings.TrimSpace(checksum) == "" && downloads != 0 {
t.Errorf("downloaded without checksum URL")
}
}
func TestUpgradeAgentRequiresChecksumURL(t *testing.T) {
exerciseUpgrade(t, "", func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected checksum request") }, false)
}
func TestUpgradeAgentRejectsChecksumHTTPFailure(t *testing.T) {
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", http.StatusNotFound) }, false)
}
const abcSHA256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
func TestUpgradeAgentRejectsAmbiguousChecksum(t *testing.T) {
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, abcSHA256+" gost-amd64\n"+abcSHA256+" gost-arm64\n")
}, false)
}
func TestUpgradeAgentRejectsOversizedChecksum(t *testing.T) {
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, abcSHA256+" "+strings.Repeat("a", 4096)) }, false)
}
func TestUpgradeAgentChecksumRecords(t *testing.T) {
for _, tc := range []struct {
name, body string
valid bool
}{
{"bare", abcSHA256, true},
{"GNU text", abcSHA256 + " gost-arm64\n", true},
{"GNU binary", abcSHA256 + " *gost-arm64\n", true},
{"uppercase CRLF surrounding whitespace", " \t" + strings.ToUpper(abcSHA256) + " gost-arm64\r\n", true},
{"bare surrounding whitespace", "\r\n\t" + abcSHA256 + " \r\n", true},
{"empty", "", false},
{"whitespace", " \r\n\t", false},
{"truncated", abcSHA256[:63], false},
{"nonhex", "z" + abcSHA256[1:], false},
{"mismatch", strings.Repeat("0", 64), false},
{"missing GNU marker", abcSHA256 + " gost-arm64", false},
{"invalid GNU marker", abcSHA256 + " ?gost-arm64", false},
{"missing filename", abcSHA256 + " *", false},
{"appended garbage", abcSHA256 + "garbage", false},
{"embedded CR", abcSHA256 + " gost\rarm64", false},
{"NUL", abcSHA256 + " gost\x00arm64", false},
{"two bare digests", abcSHA256 + "\n" + abcSHA256, false},
} {
t.Run(tc.name, func(t *testing.T) {
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, tc.body) }, tc.valid)
})
}
}
func TestUpgradeAgentRejectsChecksumTransportFailure(t *testing.T) {
exerciseUpgrade(t, "://invalid", nil, false)
}
func TestUpgradeAgentRejectsChecksumReadFailure(t *testing.T) {
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
// A valid-looking record is incomplete relative to the declared body length.
w.Header().Set("Content-Length", "999")
fmt.Fprint(w, abcSHA256)
}, false)
}
func TestUpgradeAgentRejectsBlankChecksumURL(t *testing.T) {
exerciseUpgrade(t, " \t\r\n", nil, false)
}
func TestUpgradeAgentRejectsChecksumConnectionFailure(t *testing.T) {
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
conn.Close()
}, false)
}
+87 -60
View File
@@ -14,7 +14,6 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"runtime/debug"
"strconv"
@@ -158,6 +157,11 @@ const (
)
type WebSocketReporter struct {
// Per-reporter upgrade dependencies keep staging and process launch isolated.
upgradeBinaryPath string
startUpgradeRestart func(string, string) error
detectAgentManager func() (agentServiceManager, error)
url string
addr string // 保存服务器地址
secret string // 保存密钥
@@ -1377,6 +1381,23 @@ func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, messa
w.sendResponse(response)
}
// parseUpgradeChecksum accepts one bare SHA256 or one GNU sha256sum record.
// A manifest cannot safely identify the requested binary by its first record.
func parseUpgradeChecksum(body []byte) (string, error) {
record := strings.TrimSpace(string(body))
if len(record) < 64 || strings.ContainsAny(record, "\r\n\x00") {
return "", fmt.Errorf("校验文件格式无效: 需要单条 SHA256 记录")
}
digest := record[:64]
if _, err := hex.DecodeString(digest); err != nil {
return "", fmt.Errorf("校验文件格式无效: SHA256 必须为 64 位十六进制")
}
if len(record) != 64 && (len(record) < 67 || record[64] != ' ' || (record[65] != ' ' && record[65] != '*') || strings.TrimSpace(record[66:]) == "") {
return "", fmt.Errorf("校验文件格式无效: 需要单条 GNU SHA256 记录")
}
return digest, nil
}
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
@@ -1394,6 +1415,10 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
return fmt.Errorf("下载地址不能为空")
}
if strings.TrimSpace(req.ChecksumURL) == "" {
return fmt.Errorf("校验文件地址不能为空")
}
// 替换架构占位符
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
@@ -1402,9 +1427,11 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
// 下载新版本二进制
const binaryPath = "/etc/flux_agent/flux_agent"
binaryPath := w.upgradeBinaryPath
if binaryPath == "" {
binaryPath = "/etc/flux_agent/flux_agent"
}
tmpPath := binaryPath + ".new"
backupPath := binaryPath + ".old"
resp, err := http.Get(downloadURL)
if err != nil {
@@ -1421,6 +1448,14 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
return fmt.Errorf("创建临时文件失败: %v", err)
}
// The restart process owns the staged file only after launch succeeds.
restartStarted := false
defer func() {
if !restartStarted {
_ = os.Remove(tmpPath)
}
}()
// 带进度的下载
totalSize := resp.ContentLength
var downloaded int64
@@ -1433,7 +1468,6 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
if n > 0 {
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
outFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("写入升级包失败: %v", wErr)
}
hasher.Write(buf[:n])
@@ -1451,104 +1485,97 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
break
}
outFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("读取升级包失败: %v", readErr)
}
}
outFile.Close()
if err := outFile.Close(); err != nil {
return fmt.Errorf("关闭升级包失败: %w", err)
}
if downloaded == 0 {
os.Remove(tmpPath)
return fmt.Errorf("下载的升级包为空")
}
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
// Checksum 校验
if checksumURL != "" {
// Every checksum failure must abort before installation.
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
checksumResp, err := http.Get(checksumURL)
if err == nil {
if err != nil {
return fmt.Errorf("下载校验文件失败: %w", err)
}
defer checksumResp.Body.Close()
if checksumResp.StatusCode == http.StatusOK {
checksumBody, err := io.ReadAll(checksumResp.Body)
if err == nil {
// 格式: "<hash> <filename>" 或 "<hash>"
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
if checksumResp.StatusCode != http.StatusOK {
return fmt.Errorf("下载校验文件失败, HTTP状态码: %d", checksumResp.StatusCode)
}
const maxChecksumBytes = 4096
checksumBody, err := io.ReadAll(io.LimitReader(checksumResp.Body, maxChecksumBytes+1))
if err != nil {
return fmt.Errorf("读取校验文件失败: %w", err)
}
if len(checksumBody) > maxChecksumBytes {
return fmt.Errorf("校验文件过大: 最大 %d bytes", maxChecksumBytes)
}
expectedHash, err := parseUpgradeChecksum(checksumBody)
if err != nil {
return err
}
actualHash := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(expectedHash, actualHash) {
os.Remove(tmpPath)
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
}
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
}
}
}
w.sendUpgradeProgress("verifying", 100, "校验通过")
}
if err := os.Chmod(tmpPath, 0755); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("设置执行权限失败: %v", err)
}
// 备份旧版本
// Resolve the manager before mutating an existing rollback backup.
startRestart, err := w.agentRestartLauncher(true)
if err != nil {
return err
}
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
if _, err := os.Stat(binaryPath); err == nil {
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
oldData, err := os.ReadFile(binaryPath)
if err == nil {
_ = os.WriteFile(backupPath, oldData, 0755)
fmt.Println("📦 旧版本已备份到", backupPath)
if err := backupAgentBinary(binaryPath); err != nil {
return fmt.Errorf("备份旧版本失败: %w", err)
}
}
w.sendUpgradeProgress("installing", 80, "准备重启...")
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
// 执行重启脚本
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
script := buildAgentRestartScript(tmpPath, binaryPath)
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
if err := cmd.Start(); err != nil {
os.Remove(tmpPath)
if err := startRestart(tmpPath, binaryPath); err != nil {
return fmt.Errorf("启动重启脚本失败: %v", err)
}
restartStarted = true
w.sendUpgradeProgress("installing", 100, "重启中...")
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
return nil
}
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
const binaryPath = "/etc/flux_agent/flux_agent"
backupPath := binaryPath + ".old"
// 检查备份文件是否存在
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
return fmt.Errorf("没有可用的备份文件,无法回退")
binaryPath := w.upgradeBinaryPath
if binaryPath == "" {
binaryPath = "/etc/flux_agent/flux_agent"
}
fmt.Println("🔄 开始回退到旧版本...")
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动回退脚本失败: %v", err)
startRestart, err := w.agentRestartLauncher(false)
if err != nil {
return err
}
if info, err := os.Stat(binaryPath); err != nil || !info.Mode().IsRegular() {
return fmt.Errorf("回退目标不是可用的普通文件: %s", binaryPath)
}
staged, err := stageAgentCopy(binaryPath+".old", binaryPath)
if err != nil {
return fmt.Errorf("准备回退备份失败: %w", err)
}
if err := startRestart(staged, binaryPath); err != nil {
_ = os.Remove(staged)
return fmt.Errorf("启动回退脚本失败: %w", err)
}
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
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,
)
return buildManagedAgentRestartScript(agentServiceManager{kind: "systemd", control: "systemctl"}, tmpPath, binaryPath, true)
}
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
+5 -5
View File
@@ -186,16 +186,16 @@ func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) {
script := buildAgentRestartScript("/tmp/flux_agent.new", "/etc/flux_agent/flux_agent")
if !strings.Contains(script, "systemctl stop 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") {
if !strings.Contains(script, "mv -f -- '/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") {
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") {
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") {
@@ -204,7 +204,7 @@ func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) {
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") {
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") {
@@ -0,0 +1,27 @@
# Agent upgrade checksum fail-closed
## Scope
Fix the panel-issued UpgradeAgent flow only. Do not change Alpine support, installer scripts, release artifacts, production state, or publish changes.
## Observed defect
`handleUpgradeAgent` treats checksum fetch errors, non-200 responses and body read errors as success and emits `校验通过`. An absent checksum URL bypasses verification entirely. The panel already supplies a checksum URL for individual and batch upgrades, and the release workflow generates GNU sha256sum files.
## Intended behavior
- Require a nonblank checksum URL before downloading an upgrade.
- Abort before chmod, backup, or restart on fetch/status/read/format/hash failure; remove the staged binary.
- Only emit verification success after a complete, valid SHA256 record matches the downloaded bytes.
- Accept one bare 64-hex SHA256 or a single GNU sha256sum record (text or binary marker), case-insensitive hex and surrounding whitespace/CRLF. Reject empty, truncated, nonhex, multiline and oversized payloads. Do not treat an ambiguous multi-file manifest as a checksum.
- Preserve architecture substitution and current installation flow. This is integrity checking, not a signature or an independent guarantee against a compromised download source.
## Verification plan
User-confirmed seam: actual UpgradeAgent command handling, loopback HTTP fixtures, isolated binary paths and an intercepted restart action. Assert error/response and progress, untouched old executable/backup, absence of staged bytes and no restart on every invalid checksum condition. Valid records should reach the intercepted restart with exact downloaded bytes.
Use red-green slices, then race tests, module checks, build and independent scope review. No push, PR, merge, tag or deployment is authorized for this task.
## Review scope
User confirmed review against rc8 commit `fadc2b6c122df99140e0c9cbd481a1214d17d175`, covering all changes for this task and this plan. Run independent Standards and Spec reviews after the implementation is ready.
@@ -0,0 +1,29 @@
# Agent panel upgrade and rollback on OpenRC
## Scope
Repair the agent UpgradeAgent/RollbackAgent paths on Alpine/OpenRC without regressing systemd. Preserve the pending checksum fail-closed repair. Do not change live nodes, install components, publish or deploy.
## Evidence
In a disposable cached Alpine 3.22.5 linux/amd64 container, the actual handlers fail launching `systemd-run` after successful download and verification, and also when rolling back an existing backup. Installer scripts already support OpenRC, but their CI does not exercise these command handlers.
## Safety requirements
- Prefer OpenRC on Alpine, including when a systemctl shim exists; refuse missing required manager tools.
- Keep systemd's independent transient unit behavior to survive stopping the agent cgroup.
- Launch an independent OpenRC restart helper that survives termination of the original agent, without installing extra utilities.
- Use the selected manager for stop/start; do not run systemd legacy-service cleanup on OpenRC.
- Retain validated upgrade staging and backup behavior, and do not consume the rollback backup.
- Reject setup/launch errors before claiming restart was launched. Report asynchronous-completion limits honestly.
- Preserve existing command response, request ID and progress contracts.
## Proposed tests and review
User-confirmed boundaries: real UpgradeAgent/RollbackAgent command routing, isolated files, systemd/OpenRC tools as test doubles, no-manager rejection and launch/stop/replacement failures. Use cached Alpine to execute the helper with rc-service doubles, not a real OpenRC installation. Explicitly distinguish this from service-manager integration testing.
User-confirmed review baseline is fadc2b6, covering both pending fixes and plans. No commits, push, PR, merge, release or deployment.
## Publication authorization
After the local implementation, verification and independent reviews were complete, the user authorized submitting a PR, merging if checks pass, and publishing the next tag. Earlier no-publication limits above describe the implementation phase. Deployment to live nodes remains outside scope.
+59
View File
@@ -0,0 +1,59 @@
# Panel self-upgrade reliability plan
## Scope
Fix the panel's Docker Compose self-upgrade flow. The report is from a third-party deployment; this work uses repository analysis and isolated tests, not access to that deployment. Base: `a784350` (`3.0.1-rc10`). No publishing or deployment is included.
## Confirmed problems
- The UI stops at helper launch. It cannot distinguish a running, successful, or failed upgrade.
- The helper resolves the Compose project and relative bind mounts from `/opt/flvx-panel`, which need not match the original host deployment.
- The HTTP mutex ends before the helper. Repeated requests can change the same configuration and overwrite logs and fixed backups.
- Active configuration is replaced before images are pulled; helper failures do not restore it.
- Successful `compose up` is not an explicit verification of both target images and frontend availability.
These are code defects and failure risks, not proof of the reported deployment's particular failure.
## Implementation plan
1. Identify and validate the existing Compose project, host deployment mount, backend service, and configuration paths before a task can mutate deployment files. Fail safely on unsupported or ambiguous deployment layouts.
2. Add a durable task with a unique ID, per-task log and immutable backups. Keep the task lock across the HTTP request, helper execution, and backend replacement. Reconcile stopped helpers conservatively; uncertainty must not permit overlapping upgrades.
3. Run a dedicated helper command in the current backend image. Validate a staged Compose configuration and pull images before replacing active files. Use atomic file replacement. Restore configuration only when failure is known to precede service replacement. A partial or uncertain service replacement requires explicit recovery rather than an unsafe automatic database/container rollback.
4. Record progress and terminal results locally, with a bounded execution deadline. Verify target services before success. Expose only safe progress messages through an admin status endpoint, never `.env` or arbitrary command output.
5. Add frontend progress, reconnect handling, stale-response protection, reload recovery, and repeat-click prevention. A launch acknowledgment is not completion.
6. Run focused fault-injection tests, the full backend checks, frontend lint/type/build checks, and UI lifecycle exercises. Document real Docker and PostgreSQL tests that cannot run locally.
## API contract
- Existing `POST /api/v1/system/upgrade`: preserve existing response fields and add `jobId`.
- New `POST /api/v1/system/upgrade/status`: accept optional `jobId`; return `currentVersion` and nullable `job`.
- A job includes `id`, `targetVersion`, `stage`, `active`, `requiresRecovery`, `message`, `startedAt`, `updatedAt`, and optional helper/backup references.
- Stages: `preparing`, `pulling`, `applying`, `restarting`, `verifying`, `succeeded`, `failed`, `recovery_required`.
- Status reads do not call GitHub. Timestamps use Unix milliseconds. A recovery-required task must not silently permit another upgrade.
## Required regression coverage
- Original project names and host paths that differ from the in-container path; invalid/missing labels and mounts; IPv4/IPv6 templates.
- Validation and pull failure without changing active config; application failure with safe restoration; partial recreation and verification failures with preserved recovery evidence.
- Duplicate requests, interrupted starts, stopped helpers, backend restart, task timeout, and preservation of older backups and logs.
- Frontend reload, transient disconnect, success, failure, recovery required, repeated clicks, unmount, and stale responses.
- No sensitive environment contents in status responses or browser-visible error messages.
## Rollout boundary
The launcher and helper are supplied by the version currently running, not by the target Compose download. Existing rc10 installations do not acquire this fix until the corrected backend image is actually installed. The first transition to the fixed release should use the established host-side upgrade process with the original deployment directory and Compose project. Do not recommend repeated retries of the old web updater or a blind rollback after partial service replacement.
## Verification record
Implementation completed in `fix/panel-upgrade-jobs`, based on `a784350`.
- Backend: full `go test ./...`, `go test -race ./internal/panelupgrade`, `go vet ./...`, native build, and CGO-disabled Linux amd64/arm64 builds passed with Go 1.26.10.
- Added an admin/non-admin/anonymous status contract, idle read with no Docker dependency, malformed job-ID rejection, and focused upgrade tests in CI.
- Fake-Docker tests cover configuration/pull/recreate/verification failures, partial configuration restoration, immutable task backups, uncertain launch, cross-manager blocking, original project/path, full service-setting preservation, unexpected database mounts, and safe public messages.
- Frontend: changed-file ESLint (zero warnings), TypeScript and production build passed with Node 24.22.1. Nine temporary lifecycle replays passed, including two-admin refresh, repeated submissions, unknown transport outcomes, stale replies, unmount, and bounded polling.
- Mocked rendered checks passed at 1280×900 and 390×844: confirmation, pulling, an injected 503 disconnect, reconnect, verified completion, reload discovery, and recovery blocking. The pre-existing Switch `classNames` React warning remains; the deliberate 503 was expected. No new framework error overlay or mobile horizontal overflow was found.
- Independent review identified and closed full-service configuration preservation, shadow database mounts, and terminal refresh discovery issues. `git diff --check` passed.
Limitations: no real Docker daemon upgrade was run; local daemon access was unavailable to this task. Fake Docker commands do not prove real Compose/Engine behavior. The PostgreSQL-specific contract was explicitly skipped because `FLVX_POSTGRES_TEST_DSN` was not set. No third-party deployment, SSH, publishing, release, or production restart was performed during implementation.
The conservative operator recovery procedure is in `go-backend/internal/panelupgrade/README.md`. Full service-setting changes, unsupported deployment layouts, or a possible partial recreation require administrator handling instead of silently changing live settings or automatically rolling back a database. Per-task logs contain safe phase summaries, not raw secret-bearing Docker output.
+98
View File
@@ -0,0 +1,98 @@
# FLVX #574 / #575: current owner names and custom order
Status: implemented and verified locally on 2026-11-10. The user confirmed the three test boundaries before implementation and later authorized the PostgreSQL import repair discovered during validation.
Base: `3209ff8a59bf02287e6934d0b80b941006db239e` (main, verified 2026-11-10).
Sources: https://github.com/Sagit-chu/flvx/issues/574 and https://github.com/Sagit-chu/flvx/issues/575.
## Problem statement
Existing forwarding rules retain the username copied when they were created. Renaming an account leaves the rules page's owner filter and related displays showing the old name, including after restart. Users and speed limits have only ID-descending order, so an administrator cannot arrange them intentionally.
## Solution
Resolve a forwarding rule's current owner name by immutable user ID without recreating rules. Add administrator-managed global drag order for users and speed limits, persisted on the server across clients. Newly created entries appear first. Searching disables dragging so a partial result cannot overwrite the complete order.
## User stories
1. As an administrator, I see the current owner name on existing rules after renaming a user.
2. As a user changing my account name and password, my existing rules display the new name.
3. As an administrator, I see consistent names in rule groups, owner filters and tunnel deletion previews.
4. As an administrator restoring an old backup, I do not have to delete or recreate rules to correct owner names.
5. As a user, a different account reusing my former name cannot acquire my rules or permissions.
6. As an administrator, I can drag ordinary users into a global order in both card and table views.
7. As an administrator, I can drag speed limits into a global order in both card and table views.
8. As an administrator switching browsers or signing in again, I see the server's saved order.
9. As an administrator creating an item, I find it at the top; editing it keeps its position.
10. As an administrator searching a list, I see why dragging is unavailable and hidden items retain their positions.
11. As an administrator with more than ten users, I do not lose entries from the reorder set after creating a user.
12. As an administrator whose save fails or conflicts, I see the failure and recover the authoritative order rather than a false success.
13. As an operator upgrading an existing SQLite or PostgreSQL database, I retain the prior default order until I rearrange it.
14. As an operator restoring a new backup, I retain its order; older backups remain importable.
15. As a user, an administrative display-order change does not change my password, ownership, permissions, traffic totals, limits, or rule bindings.
16. As an operator restoring PostgreSQL data, I can immediately create new records without restarting, with existing sequence allocations preserved and failed imports rolled back.
## Implementation decisions
- Rule ownership remains numeric user ID. Display uses the current associated username, with the stored name (and existing unknown-owner UI) as a compatibility fallback when no associated user exists.
- Keep the legacy stored name field and backup format compatible. Display correction must work for stale data without a destructive rewrite or rule recreation.
- Cover all relevant presentation projections, not just the owner selector. Do not change data-plane service identity or authentication behavior.
- Add integer ordering to ordinary users and speed limits. Preserve old ID-descending order for tied/default ranks, set new items before the current first rank, and preserve rank on ordinary edits.
- Add administrator-only reorder endpoints for both resources. A request supplies the complete ordered ID set and its previously observed order so stale sets/orders can be rejected safely; return an actionable conflict/error rather than partial success.
- Validate input as a complete permutation of eligible current resources. Reject malformed IDs, duplicates, missing/extra IDs and administrator user IDs. All writes are transactional.
- Serialize client saves or disable dragging while a save is outstanding. On failure/conflict, reload the server's order. Cancel or ignore stale list requests when search/navigation changes.
- Reuse existing drag sensors and affordances. Support mouse, touch and keyboard. Keep delete/edit controls usable and prevent accidental drag activation.
- Search disables drag. User creation reloads the authoritative complete list, avoiding the existing ten-item truncation.
- Preserve existing API envelopes and raw JWT convention. Explicitly protect the new user reorder path in authorization middleware.
- User and speed-limit backup DTOs, export mappings and import updates carry order. Missing old-backup fields must not accidentally zero an existing custom order. New records imported without rank follow a documented deterministic rule.
- No new frontend test framework; use the repository's build/lint and isolated browser checks.
## Testing decisions (confirmed boundaries)
1. **Public HTTP API**: current-name responses, all rename entry points, ownership isolation, admin-only reorder, complete-set validation, failures/conflicts, and list/create/edit/delete behavior. This catches application behavior but not browser interactions or every database upgrade path.
2. **Repository lifecycle and backup public interfaces**: fresh/legacy databases, restart persistence, old/new backup round trips and partial import behavior for SQLite and PostgreSQL. This catches persisted compatibility but not UI behavior. Observe through public reads/exports rather than implementation-coupled assertions where possible; legacy schema fixtures may require direct fixture setup.
3. **Rendered browser UI**: table/card drag, touch/keyboard affordances, search-disabled state, recovery from failed saves, repeat actions, and navigation. This catches actual UI wiring, not production connectivity or real forwarding.
Use Matt Pocock's vertical red-to-green cycles: one failing behavior test, minimum implementation, then the next behavior. Prefer existing contract fixtures and public interfaces. Review standards and requirements independently after implementation. These boundaries were confirmed by the user before implementation tests were added.
## Work graph
- **T1 Current owner names**: implement #574 with API regression coverage and historical/backup cases. Independent.
- **T2 Persistent order backend**: implement ordering models, migrations, endpoints, permissions, transaction/conflict handling and backup compatibility. Runs in an isolated worktree; integrate after T1 and resolve any shared repository-layer edits before reporting complete.
- **T3 Drag order frontend**: implement both list views and failure/search behavior against the agreed API contract. Can proceed after T2 contract is pinned; no backend edits.
- **T4 Verify and review**: integrate all slices, full backend checks, frontend checks, isolated SQLite/PG/browser verification, independent standards/spec reviews and fixes. Depends on T1–T3. Complete.
- **T5 PostgreSQL import sequences**: user-authorized follow-on repair for the pre-existing explicit-ID import bug. Import takes the complete set of write-table locks with NOWAIT before any data writes, retries only pre-write lock contention within a bounded budget, and advances actual ID sequences transactionally without moving allocated IDs backward. Complete.
## Out of scope
Production access or changes, external comments/issues/spec publication, personal per-admin preferences, drag while searching, server pagination/cross-page drag, field-based sorting, changing actual speed-limit enforcement, agent changes, and unrelated refactors. Push/PR/merge/release were excluded from implementation and authorized separately afterward as recorded below.
## Further notes
Work is isolated on `fix/user-names-custom-order`; original checkout remains untouched. Local commits were permitted by the implementation workflow; publication required the separate authorization recorded below. Existing assessment reproductions are available in a temporary isolated checkout for reference, not as final tests. PostgreSQL testing used an isolated, disposable local PostgreSQL 16.15 container with per-test schemas. No production database or user data was used.
## Verification results
Final implementation merge: `2ad30e4b2f587b0118cb53049194c915fb32565e`.
- Full backend suite passed with PostgreSQL enabled and the cache disabled: `FLVX_POSTGRES_TEST_DSN=... go test -count=1 ./...`.
- Race-enabled owner-name, display-order and PostgreSQL backup-import contracts passed. The new PostgreSQL import contracts also passed three repeated race-enabled runs in their implementation worktree.
- `go vet ./...`, native backend build and Linux amd64/arm64 cross-builds passed using Go 1.26.10.
- Frontend `tsc --noEmit`, full `eslint .`, and production build passed using Node 24.22.1. The build still reports the existing outdated Browserslist data notice.
- SQLite and PostgreSQL contracts cover three rename paths, old-name reuse/ownership isolation, historical fallback, previews, restart and old/new backups. Order contracts cover administrator checks, complete permutations, stale order/membership, concurrent creates/reorders, new-first and edit-preserved rank.
- PostgreSQL import tests reproduce the prior 23505 failure and cover immediate public API creates without reopening the repository, all 11 explicit-ID tables, allocated/cached IDs, non-unit positive increments, unowned legacy defaults, search-path resolution, concurrent imports/creates, cascade-delete contention, bounded busy recovery, intermediate-sequence failure and commit-time rollback. Removing NOWAIT makes the contention regression fail.
- Chrome tests against synthetic localhost data passed: user and limit card keyboard reorder; user and limit table pointer reorder; 390px viewport touch reorder; refresh persistence; 13-user creation without truncation; new-first and edit-preserved order; search-disabled handles; drag cancellation; a second client's conflict followed by authoritative recovery; transport failure and successful load-error retry.
- The rendered rules page and owner filter show the current username for a historical rule whose stored snapshot still has the former name.
- Restarting the final backend binary preserved the 13-user order, four-limit order and current-name projection.
- Independent standards and requirements reviews passed after fixes. The recovered-load error is now cleared without hiding save warnings. The PostgreSQL lock-order and legacy-default findings were resolved and re-reviewed.
## Validation limits
- Linux amd64/arm64 binaries were cross-built, not run on those target machines. No live forwarding agents, production deployment, real traffic, Safari or physical mobile device were exercised. Mobile touch was tested through Chrome's emulated viewport and touch input.
- PostgreSQL identity-column fallback was reviewed but does not have a dedicated fixture. Normal repository writes and imports are covered; external SQL that reserves sequence values inside an open transaction and later acquires tables in reverse order, or concurrent manual sequence-definition changes, are outside the tested contract. Negative/non-positive ID sequences are rejected with a rolled-back import.
- Browser logs included existing React Router/dialog accessibility warnings and a password-manager-extension error. A development HMR hook-order error during the hook edit cleared on full reload; final page flows did not reproduce it.
- At completion of local verification, all changes remained on a local branch. No push, pull request, issue comment, release or production change had been performed.
## Publication stage
After reviewing the PostgreSQL identity-column testing boundary, the user separately authorized pushing the branch, creating and merging a pull request, and publishing a new tag/release on 2026-11-10. Publication must respect required checks and branch protection. The tag workflow publishes versioned/latest images and release attachments; it does not deploy production services. Production access or changes remain out of scope.
@@ -0,0 +1,32 @@
# Agent checksum upgrade validation
## Scope and baseline
Reviewed local changes against `fadc2b6c122df99140e0c9cbd481a1214d17d175` (rc8), as confirmed by the user. Tests use the actual UpgradeAgent command router and WebSocket response path, loopback HTTP, temporary executable/backup paths, and an intercepted restart action. No real installer or service process is launched.
## Reproduction and fix
Four red-to-green slices reproduced missing URL, checksum HTTP 404, ambiguous multiple records, and oversized checksum acceptance. Initially the command returned success, emitted verification/install progress, and reached the restart substitute. The corrected flow returns an unsuccessful UpgradeAgentResponse before installation and removes staged bytes.
Additional cases cover blank URL, invalid request URL, connection interruption, truncated HTTP body despite a valid-looking hash, empty/whitespace checksum, short/nonhex/mismatched hash, invalid GNU marker, absent filename, garbage, NUL and embedded newline. Valid fixtures cover bare digest, GNU text and binary records, uppercase hex, CRLF and surrounding whitespace. Both URL architecture placeholders and response request IDs are checked. Failed cases preserve old executable and backup; valid cases reach the intercepted restart with exact `abc` bytes and the independent known SHA256 fixture.
## Checks
- Initial socket baseline: passed.
- UpgradeAgent targeted tests and race tests: passed.
- Socket package race tests, three runs: passed (2.009s after cleanup).
- go-gost/x: `go test ./...` and `go vet ./...`: passed.
- go-gost: `go test ./...`, `go vet ./...`, and build to a temporary output: passed.
- `git diff --check`: passed.
- All listed module checks and the build were rerun after cleanup and passed.
- Go toolchain: 1.26.10 darwin/arm64.
## Independent review
Standards: no hard violations or blockers. One optional duplicate cleanup observation was addressed by consolidating staging removal in the defer.
Spec: no actionable deviations found. Missing checksum, fetch/status/read/format/hash errors stop before success and installation; supported SHA256 formats and architecture substitution are retained. No unrelated installer, Alpine, release or deployment changes.
## Limits
This verifies the agent command and integrity gate, not actual systemd installation or live-node deployment. The fix must be installed on an agent before that agent gains the new behavior; updating the panel alone cannot retrofit old agents. Hash comparison is not a signature and does not protect against a compromised source supplying both artifact and matching checksum. No push, PR, merge, tag or deployment was performed. HTTP request timeout and installer/platform redesign remain outside this repair.
@@ -0,0 +1,43 @@
# OpenRC agent upgrade and rollback validation
## Baseline and scope
User-approved review baseline: fadc2b6c122df99140e0c9cbd481a1214d17d175. Includes the pending checksum fail-closed repair (071) and manager-aware upgrade/rollback (072). No publication, deployment, or new software installation.
## Reproduction
A disposable existing Alpine 3.22.5 linux/amd64 image on Mac mini Docker reproduced both failures: actual upgrade and rollback handlers could not find systemd-run. The upgrade used a valid checksum. Source inspection confirmed install.sh supports OpenRC but the two agent handlers did not. Existing installer CI did not test those command handlers.
## Regression coverage
Actual UpgradeAgent/RollbackAgent routing and WebSocket responses cover manager selection, Alpine preference over systemctl shims, missing manager, rejected submission, backup preservation, atomic rollback staging, request IDs and all checksum cases from 071. Script execution checks stop/replace/start order, stop failure, replacement failure recovery, directory targets and no systemd legacy cleanup on OpenRC.
Linux-only integration executes the real detached helper. An rc-service double sends SIGTERM to the original test-agent's entire process group. The test verifies that the original process receives SIGTERM while the helper survives, replaces the binary with the rollback copy, preserves the backup and invokes the start command.
## Environment and checks
- Mac mini: Go 1.26.10 darwin/arm64.
- Cached Alpine 3.22.5 linux/amd64 under Docker emulation; no package installation. Tests use loopback HTTP and service-command doubles, with no host service mounts.
- Full socket tests and focused race tests: passed.
- Alpine manager/upgrade/rollback/checksum/helper-survival suite: passed.
- go-gost/x full `go test ./...`, `go vet ./...`: passed.
- go-gost `go test ./...`, `go vet ./...`, build to temporary output: passed.
- `git diff --check`: passed.
## Limitations
Real OpenRC/systemd service integration was not run. The detached process test models the installed OpenRC PID/process-group stopping behavior, not custom cgroup policies. Successful command response acknowledges helper launch or systemd submission, not completed asynchronous service restart. No automatic downgrade is attempted if starting the replaced executable fails. Live nodes were not changed. HTTP request timeouts, release/deployment, and retrofitting already installed agents are outside this local repair.
## Independent review
Standards: no documented violations or blockers. Optional one-line legacy restart-script wrapper cleanup was deferred to preserve the existing focused legacy-service regression interface.
Spec: no blocking implementation deviation. Reviewer requested explicit OpenRC process-launch-failure coverage; follow-up validation is tracked below.
Follow-up: explicit OpenRC exec-failure coverage passed for both routed commands. A Linux-only, explicitly opted-in disposable Alpine test temporarily hides only the container's /bin/sh symlink and restores it afterward. It uses production Alpine detection, executable lookup and cmd.Start; both commands reject ENOENT, preserve the live executable and expected backup, and remove staging. No runtime test abstraction was added. The test requires Alpine and Docker markers and is skipped by default.
Reproduction of the opt-in case (only a disposable container; never mount host /bin): build the socket test binary with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test -c -o /tmp/flvx-072-socket.test ./socket` from go-gost/x, then run `docker run --rm --pull=never --platform linux/amd64 --network none -e FLVX_TEST_DISPOSABLE_ALPINE_SHELL_FAILURE=1 -v /tmp/flvx-072-socket.test:/probe/socket.test:ro alpine:3.22 /probe/socket.test -test.run '^TestOpenRCCommandsCleanStageOnHelperExecFailure$' -test.v`.
Final host full socket race suite passed in 8.844s. Final follow-up changed only Linux test coverage; Mac socket/vet and diff checks passed again.
Independent Spec re-review confirmed the launch-failure gap is closed, with no remaining actionable spec gaps.
+14
View File
@@ -71,6 +71,20 @@ http {
}
# API代理到后端
location = /api/v1/tunnel/batch-redeploy/stream {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://backend:6365/api/v1/tunnel/batch-redeploy/stream;
}
location = /api/v1/tunnel/diagnose/stream {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+18
View File
@@ -44,6 +44,7 @@ import type {
StorageSummaryApiData,
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeStatusApiData,
SystemUpgradeVersionApiData,
} from "./types";
@@ -317,6 +318,13 @@ export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") =>
channel,
});
export const getSystemUpgradeStatus = (jobId?: string) =>
Network.post<SystemUpgradeStatusApiData>(
"/system/upgrade/status",
jobId ? { jobId } : {},
{ timeout: 10000 },
);
export const runSystemUpgrade = (
version?: string,
channel: ReleaseChannel = "stable",
@@ -611,3 +619,13 @@ export const assignMonitorPermission = (userId: number) =>
export const removeMonitorPermission = (userId: number) =>
Network.post("/monitor/permission/remove", { userId });
// Complete ordered IDs plus the last observed server order for conflict detection.
export interface OrderUpdatePayload {
ids: number[];
expectedIds: number[];
}
export const updateUserOrder = (data: OrderUpdatePayload) =>
Network.post("/user/update-order", data);
export const updateSpeedLimitOrder = (data: OrderUpdatePayload) =>
Network.post("/speed-limit/update-order", data);
+15 -2
View File
@@ -46,6 +46,7 @@ interface ApiResponse<T = unknown> {
code: number;
msg: string;
data: T;
transportError?: boolean;
}
interface RequestOptions {
@@ -82,7 +83,12 @@ const Network = {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
if (baseURL === "") {
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
resolve({
code: -1,
msg: " - 请先设置面板地址",
data: null as T,
transportError: true,
});
return;
}
@@ -121,6 +127,7 @@ const Network = {
resolve({
code: -1,
transportError: true,
msg: errorMessage,
data: null as T,
});
@@ -136,7 +143,12 @@ const Network = {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
if (baseURL === "") {
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
resolve({
code: -1,
msg: " - 请先设置面板地址",
data: null as T,
transportError: true,
});
return;
}
@@ -175,6 +187,7 @@ const Network = {
resolve({
code: -1,
transportError: true,
msg: errorMessage,
data: null as T,
});
@@ -0,0 +1,287 @@
import type { BatchOperationResult } from "@/api/types";
import axios from "axios";
import { clearSession, getToken } from "@/utils/session";
const STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
const MAX_EVENT_LENGTH = 1024 * 1024;
const UNCERTAIN_RESULT =
"下发进度连接中断,部分配置可能已执行,结果尚未确认。请核对节点与隧道状态后再决定是否重新下发,系统不会自动重试。";
export interface TunnelRedeployProgress {
total: number;
completed: number;
success: number;
failed: number;
}
export interface TunnelRedeployProcessing {
id: number;
name: string;
progress: TunnelRedeployProgress;
}
export interface TunnelRedeployItem {
result: {
id: number;
name: string;
success: boolean;
reason?: string;
};
progress: TunnelRedeployProgress;
}
interface TunnelRedeployCallbacks {
onStart?: (progress: TunnelRedeployProgress) => void;
onProcessing?: (payload: TunnelRedeployProcessing) => void;
onItem?: (payload: TunnelRedeployItem) => void;
}
type RawObject = Record<string, unknown>;
class RequestRejectedError extends Error {}
const object = (value: unknown): RawObject => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(UNCERTAIN_RESULT);
}
return value as RawObject;
};
const readProgress = (value: unknown): TunnelRedeployProgress => {
const candidate = object(value);
const { total, completed, success, failed } = candidate;
if (
![total, completed, success, failed].every(
(count) =>
typeof count === "number" && Number.isSafeInteger(count) && count >= 0,
) ||
(completed as number) > (total as number) ||
(success as number) + (failed as number) !== completed
) {
throw new Error(UNCERTAIN_RESULT);
}
return { total, completed, success, failed } as TunnelRedeployProgress;
};
const expireSession = () => {
clearSession();
if (window.location.pathname !== "/") window.location.href = "/";
};
/** A mutation stream must never be retried or replayed through the legacy API. */
export const batchRedeployTunnelsStream = async (
ids: number[],
callbacks: TunnelRedeployCallbacks,
signal?: AbortSignal,
): Promise<BatchOperationResult> => {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
throw new Error("请选择有效的隧道");
}
if (typeof fetch !== "function" || typeof TextDecoder === "undefined") {
throw new Error("当前浏览器不支持实时下发进度,请使用支持流式响应的浏览器");
}
const requestedIds = new Set(ids);
const controller = new AbortController();
const abort = () => controller.abort();
let timer: ReturnType<typeof setTimeout> | undefined;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
const resetTimeout = () => {
clearTimeout(timer);
timer = setTimeout(abort, STREAM_IDLE_TIMEOUT_MS);
};
signal?.addEventListener("abort", abort, { once: true });
resetTimeout();
try {
const base = axios.defaults.baseURL || "/api/v1/";
const response = await fetch(
`${base.endsWith("/") ? base : `${base}/`}tunnel/batch-redeploy/stream`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/x-ndjson, application/json",
Authorization: getToken() || "",
},
body: JSON.stringify({ ids: Array.from(requestedIds) }),
signal: controller.signal,
},
);
if (response.status === 401) {
expireSession();
throw new RequestRejectedError("登录已过期,请重新登录");
}
if (response.status === 404 || response.status === 405) {
throw new RequestRejectedError(
"后端尚不支持实时下发进度,请升级后端后再操作;未自动改用旧接口下发",
);
}
if (
!response.headers.get("content-type")?.includes("application/x-ndjson")
) {
let payload: RawObject | undefined;
try {
payload = object(await response.json());
} catch {
throw new Error(UNCERTAIN_RESULT);
}
if (payload.code === 401) {
expireSession();
throw new RequestRejectedError("登录已过期,请重新登录");
}
if (typeof payload.code === "number" && payload.code !== 0) {
throw new RequestRejectedError(
typeof payload.msg === "string" ? payload.msg : "下发请求失败",
);
}
throw new Error(UNCERTAIN_RESULT);
}
if (!response.ok || !response.body) throw new Error(UNCERTAIN_RESULT);
reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let buffer = "";
let progress: TunnelRedeployProgress | undefined;
let currentId: number | undefined;
const completedIds = new Set<number>();
let finalResult: BatchOperationResult | undefined;
const processLine = (line: string) => {
if (!line.trim()) return;
const event = object(JSON.parse(line));
const data = object(event.data);
const next = readProgress(event.type === "start" ? data : data.progress);
if (
next.total !== requestedIds.size ||
(progress &&
(next.success < progress.success || next.failed < progress.failed))
)
throw new Error(UNCERTAIN_RESULT);
if (event.type === "start") {
if (progress || next.completed !== 0) throw new Error(UNCERTAIN_RESULT);
progress = next;
callbacks.onStart?.(next);
return;
}
if (!progress) throw new Error(UNCERTAIN_RESULT);
if (event.type === "processing") {
if (
typeof data.id !== "number" ||
!requestedIds.has(data.id) ||
typeof data.name !== "string" ||
completedIds.has(data.id) ||
currentId !== undefined ||
next.completed !== progress.completed
)
throw new Error(UNCERTAIN_RESULT);
currentId = data.id;
callbacks.onProcessing?.({
id: data.id,
name: data.name,
progress: next,
});
} else if (event.type === "item") {
const result = object(data.result);
if (
result.id !== currentId ||
currentId === undefined ||
typeof result.name !== "string" ||
typeof result.success !== "boolean" ||
(result.reason !== undefined && typeof result.reason !== "string") ||
next.completed !== progress.completed + 1 ||
next.success !== progress.success + (result.success ? 1 : 0) ||
next.failed !== progress.failed + (result.success ? 0 : 1)
)
throw new Error(UNCERTAIN_RESULT);
completedIds.add(currentId);
currentId = undefined;
callbacks.onItem?.({
result: result as unknown as TunnelRedeployItem["result"],
progress: next,
});
} else if (event.type === "done") {
const result = object(data.result);
const failures = result.failures ?? [];
if (
currentId !== undefined ||
next.completed !== next.total ||
next.completed !== completedIds.size ||
next.completed !== progress.completed ||
result.successCount !== next.success ||
result.failCount !== next.failed ||
!Array.isArray(failures) ||
failures.length !== next.failed ||
failures.some((failure) => {
const item = object(failure);
return (
typeof item.id !== "number" ||
!completedIds.has(item.id) ||
typeof item.reason !== "string" ||
(item.name !== undefined && typeof item.name !== "string")
);
})
)
throw new Error(UNCERTAIN_RESULT);
finalResult = result as unknown as BatchOperationResult;
} else {
throw new Error(UNCERTAIN_RESULT);
}
progress = next;
};
while (!finalResult) {
const { value, done } = await reader.read();
if (done) {
buffer += decoder.decode();
if (buffer.trim()) processLine(buffer);
break;
}
resetTimeout();
buffer += decoder.decode(value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.length > MAX_EVENT_LENGTH) throw new Error(UNCERTAIN_RESULT);
processLine(line);
if (finalResult) break;
}
if (buffer.length > MAX_EVENT_LENGTH) throw new Error(UNCERTAIN_RESULT);
}
if (!finalResult) throw new Error(UNCERTAIN_RESULT);
return finalResult;
} catch (error) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (error instanceof RequestRejectedError) throw error;
throw new Error(UNCERTAIN_RESULT);
} finally {
clearTimeout(timer);
controller.abort();
signal?.removeEventListener("abort", abort);
if (reader) {
// Cancel only the response reader, never resend an interrupted mutation.
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
};
+29
View File
@@ -550,7 +550,36 @@ export interface SystemUpgradeCheckApiData extends SystemUpgradeVersionApiData {
releases: SystemUpgradeReleaseApiItem[];
}
export type SystemUpgradeStage =
| "preparing"
| "pulling"
| "applying"
| "restarting"
| "verifying"
| "succeeded"
| "failed"
| "recovery_required";
export interface SystemUpgradeJob {
id: string;
targetVersion: string;
stage: SystemUpgradeStage;
active: boolean;
requiresRecovery: boolean;
message: string;
startedAt: number;
updatedAt: number;
helperContainer?: string;
backupDir?: string;
}
export interface SystemUpgradeStatusApiData {
currentVersion: string;
job: SystemUpgradeJob | null;
}
export interface SystemUpgradeRunApiData {
jobId: string;
version: string;
channel: "stable" | "dev";
composeAsset: string;
@@ -0,0 +1,151 @@
import { createContext, useContext, type ReactNode } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { TableRow } from "@/shadcn-bridge/heroui/table";
const HandleContext = createContext<ReactNode>(null);
export function OrderHandle() {
return <>{useContext(HandleContext)}</>;
}
export function OrderItem({
id,
label,
disabled,
row = false,
children,
}: {
id: number;
label: string;
disabled: boolean;
row?: boolean;
children: ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
const Component = row ? TableRow : "div";
return (
<HandleContext.Provider
value={
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
aria-label={`拖动排序:${label}`}
className={`inline-flex items-center justify-center shrink-0 rounded text-default-500 hover:bg-default-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-grab active:cursor-grabbing ${row ? "p-1" : "h-8 w-8 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"}`}
disabled={disabled}
style={{ touchAction: "none" }}
type="button"
>
<GripVertical aria-hidden="true" className="h-4 w-4" />
</button>
}
>
<Component
ref={setNodeRef}
className={row ? undefined : "h-full min-w-0"}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
position: "relative",
zIndex: isDragging ? 1 : undefined,
}}
>
{children}
</Component>
</HandleContext.Provider>
);
}
export function OrderList({
ids,
children,
onDragStart,
onDragEnd,
}: {
ids: number[];
children: ReactNode;
onDragStart: () => void;
onDragEnd: (event: DragEndEvent) => void;
}) {
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
<DndContext
accessibility={{
screenReaderInstructions: {
draggable: "按空格开始排序,方向键移动,再按空格保存,Escape 取消。",
},
}}
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
>
<SortableContext items={ids} strategy={rectSortingStrategy}>
{children}
</SortableContext>
</DndContext>
);
}
export function OrderStatus({
reason,
error,
retry,
}: {
reason: string;
error: string;
retry: () => void;
}) {
return (
<div className="mb-3 text-sm text-default-500">
<p role="status">
{reason || "拖动手柄调整全局顺序;键盘可按空格、方向键、空格完成排序。"}
</p>
{error && (
<p className="text-danger mt-2" role="alert">
{error}{" "}
<button className="underline" type="button" onClick={retry}>
重新加载
</button>
</p>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { DragEndEvent } from "@dnd-kit/core";
import type { OrderUpdatePayload } from "@/api";
import { useCallback, useEffect, useRef, useState } from "react";
import { arrayMove } from "@dnd-kit/sortable";
interface Result {
code: number;
msg?: string;
transportError?: boolean;
}
// Keep one complete, authoritative snapshot. Filtering is presentation-only.
export function useServerOrder<T extends { id: number }>(
fetchItems: () => Promise<T[]>,
saveOrder: (payload: OrderUpdatePayload) => Promise<Result>,
) {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveWarning, setSaveWarning] = useState("");
const mounted = useRef(false);
const request = useRef(0);
const savingRef = useRef(false);
const snapshot = useRef(items);
snapshot.current = items;
const dragSnapshot = useRef<T[] | null>(null);
const reload = useCallback(async () => {
const version = ++request.current;
setLoading(true);
try {
const rows = await fetchItems();
if (!mounted.current || version !== request.current) return;
setItems(rows);
setLoadError("");
} catch {
if (!mounted.current || version !== request.current) return;
// Never expose a partial or unverified set as reorderable after failure.
setItems([]);
setLoadError("加载列表失败,请重试后再排序。");
} finally {
if (mounted.current && version === request.current) setLoading(false);
}
}, [fetchItems]);
useEffect(() => {
mounted.current = true;
void reload();
return () => {
mounted.current = false;
++request.current;
};
}, [reload]);
const onDragStart = () => {
dragSnapshot.current = snapshot.current;
};
const reorder = async ({ active, over }: DragEndEvent, disabled: boolean) => {
const before = dragSnapshot.current;
dragSnapshot.current = null;
if (
disabled ||
loading ||
savingRef.current ||
!over ||
active.id === over.id ||
before !== snapshot.current
)
return;
const from = items.findIndex((item) => item.id === active.id);
const to = items.findIndex((item) => item.id === over.id);
if (from < 0 || to < 0) return;
savingRef.current = true;
setSaving(true);
setSaveWarning("");
try {
const result = await saveOrder({
ids: arrayMove(items, from, to).map((item) => item.id),
expectedIds: items.map((item) => item.id),
});
if (!mounted.current) return;
if (result.code !== 0) {
setSaveWarning(
result.transportError
? "排序保存未确认,请检查重新加载后的服务器顺序再重试。"
: result.code === 409
? "列表已被其他操作更改,排序未保存,请按重新加载后的顺序重试。"
: `排序保存失败:${result.msg || "请重试"}。请检查重新加载后的服务器顺序。`,
);
}
} catch {
if (mounted.current)
setSaveWarning("排序保存未确认,请检查重新加载后的服务器顺序再重试。");
} finally {
if (mounted.current) {
await reload();
if (mounted.current) setSaving(false);
}
savingRef.current = false;
}
};
return {
items,
setItems,
loading,
saving,
error: [saveWarning, loadError].filter(Boolean).join(" "),
reload,
onDragStart,
reorder,
};
}
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from "react";
import { getSystemUpgradeStatus, runSystemUpgrade } from "@/api";
import {
createUpgradeMonitor,
initialUpgradeState,
upgradeIsBlocked,
} from "@/utils/system-upgrade-monitor";
export function useSystemUpgrade(enabled: boolean) {
const [state, setState] = useState(initialUpgradeState);
const monitor = useRef<ReturnType<typeof createUpgradeMonitor> | null>(null);
useEffect(() => {
if (!enabled) return;
const instance = createUpgradeMonitor(
{ status: getSystemUpgradeStatus, run: runSystemUpgrade },
setState,
);
monitor.current = instance;
instance.refresh();
return () => {
instance.dispose();
monitor.current = null;
};
}, [enabled]);
return {
...state,
blocked: upgradeIsBlocked(state),
refresh: () => monitor.current?.refresh(),
submit: (version?: string, channel?: "stable" | "dev") =>
monitor.current?.submit(version, channel) ?? Promise.resolve(false),
};
}
+120 -23
View File
@@ -1,6 +1,5 @@
import type {
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeReleaseApiItem,
SystemUpgradeVersionApiData,
} from "@/api/types";
@@ -10,6 +9,8 @@ import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import toast from "react-hot-toast";
import { useSystemUpgrade } from "@/hooks/use-system-upgrade";
import { upgradeMessage } from "@/utils/system-upgrade-monitor";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -36,7 +37,6 @@ import {
getStorageSummary,
getSystemUpgradeVersion,
checkSystemUpgrade,
runSystemUpgrade,
type AnnouncementData,
} from "@/api";
import { BackIcon, SettingsIcon } from "@/components/icons";
@@ -336,7 +336,9 @@ export default function ConfigPage() {
const [systemUpgradeInfo, setSystemUpgradeInfo] =
useState<SystemUpgradeVersionApiData | null>(null);
const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false);
const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false);
const upgrade = useSystemUpgrade(isAdmin());
const systemUpgradeExecuting =
upgrade.submitting || Boolean(upgrade.job?.active);
const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true);
const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false);
const [systemUpgradeReleases, setSystemUpgradeReleases] = useState<
@@ -363,17 +365,38 @@ export default function ConfigPage() {
const canTriggerSystemUpgrade = Boolean(
!systemUpgradeLoading &&
!systemUpgradeChecking &&
!systemUpgradeExecuting &&
systemUpgradeInfo?.capability.capable !== false,
!upgrade.blocked &&
systemUpgradeInfo?.capability.capable === true,
);
const canOpenSystemUpgradeModal = Boolean(
systemUpgradeInfo?.capability.capable &&
systemUpgradeHasConfirmedUpdate &&
!systemUpgradeLoading &&
!systemUpgradeChecking &&
!systemUpgradeExecuting,
!upgrade.blocked,
);
useEffect(() => {
if (upgrade.job?.stage !== "succeeded" || upgrade.uncertain) return;
setSystemUpgradeReleases([]);
setSystemUpgradeCheckedChannel(null);
setSystemUpgradeSelectedVersion("");
setSystemUpgradeInfo((previous) =>
previous
? {
...previous,
currentVersion: upgrade.currentVersion,
hasUpdate: false,
}
: previous,
);
}, [
upgrade.job?.id,
upgrade.job?.stage,
upgrade.currentVersion,
upgrade.uncertain,
]);
const canGoBack =
typeof window !== "undefined" &&
typeof window.history.state?.idx === "number" &&
@@ -572,6 +595,7 @@ export default function ConfigPage() {
};
const handleOpenSystemUpgradeModal = async () => {
if (upgrade.blocked) return;
if (!canOpenSystemUpgradeModal) {
const checked = await handleCheckSystemUpgrade();
@@ -583,26 +607,15 @@ export default function ConfigPage() {
};
const handleConfirmSystemUpgrade = async () => {
setSystemUpgradeExecuting(true);
try {
const response = await runSystemUpgrade(
if (!canTriggerSystemUpgrade) return;
const submitted = await upgrade.submit(
systemUpgradeSelectedVersion || undefined,
updateChannel,
);
if (response.code === 0 && response.data) {
const data = response.data as SystemUpgradeRunApiData;
if (submitted) {
setSystemUpgradeModalOpen(false);
setSystemUpgradeSelectedVersion("");
toast.success(data.message || "升级已触发,请稍后刷新页面");
} else {
toast.error(response.msg || "面板升级失败");
}
} catch {
toast.error("面板升级失败,请重试");
} finally {
setSystemUpgradeExecuting(false);
}
};
@@ -1647,6 +1660,87 @@ export default function ConfigPage() {
</p>
</div>
<div
aria-live="polite"
className="space-y-2 rounded-lg border border-divider bg-background p-4 text-sm"
>
<p className="font-medium">
{upgrade.submitting
? "正在提交升级任务..."
: upgrade.job
? {
preparing: "正在准备升级",
pulling: "正在拉取镜像",
applying: "正在应用升级",
restarting: "正在重启服务",
verifying: "正在验证运行版本",
succeeded: "升级完成,运行版本已验证",
failed: "升级失败",
recovery_required: "升级中断,需要人工恢复",
}[upgrade.job.stage] || "升级状态待确认"
: upgrade.checking
? "正在恢复升级状态..."
: upgrade.uncertain
? "升级状态待确认"
: "暂无升级任务"}
</p>
{upgrade.job && (
<>
<p className="break-words text-default-600">
目标版本:{upgrade.job.targetVersion}
</p>
{upgrade.job.message && (
<p className="break-words text-default-600">
{upgradeMessage(upgrade.job.message)}
</p>
)}
{upgrade.job.active && (
<p className="text-default-500">
升级期间请勿重复操作。后端重启可能短暂断开连接,本页面会自动重新连接并验证结果。
</p>
)}
{upgrade.job.stage === "succeeded" && !upgrade.uncertain && (
<p className="text-success">
后端已验证运行版本 {upgrade.currentVersion}
。请刷新页面以加载新版前端。
</p>
)}
{(upgrade.job.requiresRecovery ||
upgrade.job.stage === "recovery_required") && (
<p className="text-danger">
已阻止再次升级。请联系服务器管理员检查升级辅助容器日志和部署目录中的备份,确认服务与数据库状态后按部署文档恢复;不要删除备份或反复触发升级。
</p>
)}
{upgrade.job.stage === "failed" && (
<p className="text-danger">
请先排查失败原因,再检查更新并重试。
</p>
)}
</>
)}
{upgrade.uncertain && !upgrade.checking && (
<p className="text-warning">
暂时无法确认升级结果,可能正在重启或连接已中断。这不代表升级失败,请勿重复提交。
</p>
)}
{upgrade.paused && (
<p className="text-warning">
自动查询已暂停,任务可能仍在运行。请检查服务连接后点击“重新查询状态”;不会重新发起升级。
</p>
)}
{upgrade.notice && (
<p className="text-danger">{upgrade.notice}</p>
)}
<Button
isDisabled={upgrade.checking || upgrade.submitting}
size="sm"
variant="flat"
onPress={upgrade.refresh}
>
重新查询状态
</Button>
</div>
{systemUpgradeLoading ? (
<div className="flex items-center gap-2 rounded-lg border border-divider bg-background px-4 py-3 text-sm text-default-500">
<Spinner size="sm" />
@@ -1658,7 +1752,9 @@ export default function ConfigPage() {
<div>
<p className="text-xs text-default-500">当前版本</p>
<p className="mt-1 font-medium">
{systemUpgradeInfo?.currentVersion || "未获取到版本信息"}
{upgrade.currentVersion ||
systemUpgradeInfo?.currentVersion ||
"未获取到版本信息"}
</p>
</div>
<div>
@@ -1776,6 +1872,7 @@ export default function ConfigPage() {
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
<Button
isDisabled={upgrade.blocked}
isLoading={systemUpgradeChecking}
variant="flat"
onPress={handleCheckSystemUpgrade}
@@ -2078,8 +2175,8 @@ export default function ConfigPage() {
</Button>
<Button
color="primary"
isDisabled={systemUpgradeExecuting}
isLoading={systemUpgradeExecuting}
isDisabled={!canTriggerSystemUpgrade}
isLoading={upgrade.submitting}
onPress={handleConfirmSystemUpgrade}
>
确认升级
+77 -45
View File
@@ -1,12 +1,17 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
import toast from "react-hot-toast";
import { LayoutGrid, List } from "lucide-react";
import {
AnimatedPage,
StaggerList,
StaggerItem,
} from "@/components/animated-page";
OrderHandle,
OrderItem,
OrderList,
OrderStatus,
} from "@/components/server-order";
import { useServerOrder } from "@/hooks/use-server-order";
import { getOrderDisabledReason } from "@/utils/server-order";
import { isAdmin } from "@/utils/auth";
import { AnimatedPage, StaggerList } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -24,11 +29,11 @@ import {
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createSpeedLimit,
updateSpeedLimitOrder,
getSpeedLimitList,
updateSpeedLimit,
deleteSpeedLimit,
@@ -52,9 +57,17 @@ interface SpeedLimitForm {
status: number;
}
const fetchOrderedRules = async (): Promise<SpeedLimitRule[]> => {
const response = await getSpeedLimitList();
if (response.code !== 0) throw new Error(response.msg);
return (response.data || []) as SpeedLimitRule[];
};
export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const order = useServerOrder(fetchOrderedRules, updateSpeedLimitOrder);
const { items: rules, loading, reload: loadData } = order;
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"limit-search-keyword",
"",
@@ -92,28 +105,6 @@ export default function LimitPage() {
// 表单验证错误
const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
loadData();
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
try {
const rulesRes = await getSpeedLimitList();
if (rulesRes.code === 0) {
setRules(rulesRes.data || []);
} else {
toast.error(rulesRes.msg || "获取限速规则失败");
}
} catch {
toast.error("加载数据失败");
} finally {
setLoading(false);
}
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: { [key: string]: string } = {};
@@ -166,7 +157,7 @@ export default function LimitPage() {
// 确认删除规则
const confirmDelete = async () => {
if (!ruleToDelete) return;
if (!ruleToDelete || order.saving || deleteLoading) return;
setDeleteLoading(true);
try {
@@ -175,7 +166,7 @@ export default function LimitPage() {
if (res.code === 0) {
toast.success("删除成功");
setDeleteModalOpen(false);
loadData();
await loadData();
} else {
toast.error(res.msg || "删除失败");
}
@@ -188,7 +179,7 @@ export default function LimitPage() {
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
if (order.saving || !validateForm()) return;
setSubmitLoading(true);
try {
@@ -215,7 +206,7 @@ export default function LimitPage() {
if (res.code === 0) {
toast.success(isEdit ? "修改成功" : "创建成功");
setModalOpen(false);
loadData();
await loadData();
} else {
toast.error(res.msg || "操作失败");
}
@@ -226,9 +217,13 @@ export default function LimitPage() {
}
};
if (loading) {
return <PageLoadingState message="正在加载..." />;
}
const orderReason = getOrderDisabledReason({
isAdmin: isAdmin(),
searchKeyword,
saving: order.saving,
busy: loading || submitLoading || deleteLoading,
});
const orderDisabled = Boolean(orderReason);
return (
<AnimatedPage className="px-3 lg:px-6 py-8">
@@ -263,8 +258,25 @@ export default function LimitPage() {
</div>
</div>
<OrderStatus
error={order.error}
reason={orderReason}
retry={() => {
if (!order.saving) void loadData();
}}
/>
<OrderList
key={`${viewMode}:${searchKeyword}:${orderDisabled}`}
ids={filteredRules.map((rule) => rule.id)}
onDragEnd={(event) => {
void order.reorder(event, orderDisabled);
}}
onDragStart={order.onDragStart}
>
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
{loading ? (
<PageLoadingState message="正在加载..." />
) : filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
@@ -285,9 +297,16 @@ export default function LimitPage() {
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<OrderItem
key={rule.id}
row
disabled={orderDisabled}
id={rule.id}
label={rule.name}
>
<TableCell>
<div className="flex items-center gap-2">
<OrderHandle />
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
@@ -323,7 +342,7 @@ export default function LimitPage() {
</Button>
</div>
</TableCell>
</TableRow>
</OrderItem>
)}
</TableBody>
</Table>
@@ -331,16 +350,26 @@ export default function LimitPage() {
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
<OrderItem
key={rule.id}
disabled={orderDisabled}
id={rule.id}
label={rule.name}
>
<Card className="shadow-sm overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
<div className="flex items-center gap-2 w-full min-w-0">
<div className="flex items-center gap-1 flex-1 min-w-0">
<OrderHandle />
<h3
className="font-semibold text-foreground truncate"
title={rule.name}
>
{rule.name}
</h3>
</div>
<Chip
className="shrink-0"
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
@@ -412,7 +441,7 @@ export default function LimitPage() {
</div>
</CardBody>
</Card>
</StaggerItem>
</OrderItem>
))}
</StaggerList>
)
@@ -429,6 +458,7 @@ export default function LimitPage() {
</CardBody>
</Card>
)}
</OrderList>
{/* 新增/编辑模态框 */}
<Modal
@@ -497,6 +527,7 @@ export default function LimitPage() {
</Button>
<Button
color="primary"
isDisabled={order.saving}
isLoading={submitLoading}
onPress={handleSubmit}
>
@@ -544,6 +575,7 @@ export default function LimitPage() {
</Button>
<Button
color="danger"
isDisabled={order.saving}
isLoading={deleteLoading}
onPress={confirmDelete}
>
+79 -23
View File
@@ -1,6 +1,7 @@
import type { MonitorNodeApiItem } from "@/api/types";
import type { RealtimeNodeMetric } from "@/pages/node/monitor-view";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import {
RefreshCw,
@@ -44,14 +45,23 @@ export default function MonitorPage() {
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<
Record<number, "online" | "offline">
>({});
const realtimeNodeStatusRef = useRef(new Map<number, { status: number }>());
const nodesRequestIdRef = useRef(0);
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
Record<number, any>
Record<number, RealtimeNodeMetric>
>({});
const handleRealtimeMessage = useCallback((message: any) => {
const updateNodeStatus = useCallback((nodeId: number, status: number) => {
realtimeNodeStatusRef.current.set(nodeId, { status });
setNodes((prev) =>
prev.map((node) =>
Number(node.id) === nodeId ? { ...node, status } : node,
),
);
}, []);
const handleRealtimeMessage = useCallback(
(message: any) => {
const nodeId = Number(message?.id ?? 0);
if (!nodeId || Number.isNaN(nodeId)) return;
@@ -60,12 +70,13 @@ export default function MonitorPage() {
const payload = message?.data;
if (type === "status") {
const status = Number(payload);
updateNodeStatus(nodeId, Number(payload) === 1 ? 1 : 0);
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: status === 1 ? "online" : "offline",
}));
return;
}
if (type === "info") {
updateNodeStatus(nodeId, 1);
return;
}
@@ -81,36 +92,70 @@ export default function MonitorPage() {
}
}
if (!raw || typeof raw !== "object") return;
const metric = raw as any;
const metric = raw as Record<string, unknown>;
const receivedAt = Date.now();
const incomingUptime = Number(metric.uptime ?? 0);
setRealtimeNodeMetrics((prev) => ({
...prev,
[nodeId]: {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
netOutBytes: Number(
metric.netOutBytes ?? metric.bytes_transmitted ?? 0,
),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
netOutSpeed: Number(
metric.netOutSpeed ?? metric.net_out_speed ?? 0,
),
load1: Number(metric.load1 ?? 0),
load5: Number(metric.load5 ?? 0),
load15: Number(metric.load15 ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: incomingUptime || prev[nodeId]?.uptime || 0,
},
}));
updateNodeStatus(nodeId, 1);
}
}, []);
},
[updateNodeStatus],
);
useNodeRealtime({
const { wsConnected, wsConnecting } = useNodeRealtime({
onMessage: handleRealtimeMessage,
enabled: true,
});
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
const requestId = ++nodesRequestIdRef.current;
const statusAtRequest = new Map(realtimeNodeStatusRef.current);
if (!silent) setNodesLoading(true);
try {
const response = await getMonitorNodes();
// A newer refresh or poll owns the page state, even if it finishes first.
if (requestId !== nodesRequestIdRef.current) return;
if (response.code === 0 && Array.isArray(response.data)) {
setNodesError(null);
setNodes(response.data);
// A fresh snapshot repairs missed WS events. Events received while the
// request was in flight are newer than that snapshot and must win.
setNodes(
response.data.map((node) => {
const nodeId = Number(node.id);
const latest = realtimeNodeStatusRef.current.get(nodeId);
return latest && latest !== statusAtRequest.get(nodeId)
? { ...node, status: latest.status }
: node;
}),
);
return;
}
@@ -124,14 +169,21 @@ export default function MonitorPage() {
if (!silent) toast.error(response.msg || "加载节点失败");
} catch {
if (!silent) toast.error("加载节点失败");
if (requestId === nodesRequestIdRef.current && !silent) {
toast.error("加载节点失败");
}
} finally {
if (!silent) setNodesLoading(false);
// A silent poll may supersede a foreground refresh that showed loading.
if (requestId === nodesRequestIdRef.current) setNodesLoading(false);
}
}, []);
useEffect(() => {
void loadNodes();
return () => {
nodesRequestIdRef.current += 1;
};
}, [loadNodes]);
useEffect(() => {
@@ -148,14 +200,12 @@ export default function MonitorPage() {
.map((n) => ({
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus:
realtimeNodeStatus[Number(n.id)] ||
(n.status === 1 ? "online" : "offline"),
connectionStatus: n.status === 1 ? "online" : "offline",
version: n.version,
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
}, [nodes, realtimeNodeStatus]);
}, [nodes]);
const aggregateMetrics = useMemo(() => {
let totalCpu = 0;
@@ -326,7 +376,13 @@ export default function MonitorPage() {
</div>
{activeTab === "nodes" ? (
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
<MonitorView
nodeMap={nodeMap}
realtimeNodeMetrics={realtimeNodeMetrics}
viewMode={viewMode}
wsConnected={wsConnected}
wsConnecting={wsConnecting}
/>
) : (
<TunnelMonitorView viewMode={viewMode} />
)}
+26 -14
View File
@@ -45,7 +45,6 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
@@ -70,6 +69,10 @@ import {
getConnectionStatusMeta,
getRemoteSyncErrorMessage,
} from "@/pages/node/display";
import {
NodeUpgradeProgress,
type NodeUpgradeProgressState,
} from "@/pages/node/upgrade-progress";
import { tryCopyInstallCommand } from "@/pages/node/install-command";
import {
getNodeRenewalSnapshot,
@@ -422,7 +425,7 @@ export default function NodePage() {
const [selectedVersion, setSelectedVersion] = useState("");
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }>
Record<number, NodeUpgradeProgressState>
>({});
const [infoPopoverPlacement, setInfoPopoverPlacement] = useState<
@@ -1120,6 +1123,13 @@ export default function NodePage() {
const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
if (!node) return;
setUpgradeProgress((prev) => {
const next = { ...prev };
delete next[upgradeTargetNodeId];
return next;
});
setNodeList((prev) =>
prev.map((n) =>
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,
@@ -1160,6 +1170,13 @@ export default function NodePage() {
return;
}
setUpgradeProgress((prev) => {
const next = { ...prev };
selectedLocalIds.forEach((id) => delete next[id]);
return next;
});
setBatchUpgradeLoading(true);
setUpgradeModalOpen(false);
try {
@@ -1956,6 +1973,11 @@ export default function NodePage() {
<span className="text-sm text-default-500">
{node.version || "-"}
</span>
{!isRemoteNode && (
<NodeUpgradeProgress
progress={upgradeProgress[node.id]}
/>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
@@ -2261,19 +2283,9 @@ export default function NodePage() {
{node.version || "未知"}
</span>
</div>
{upgradeProgress[node.id] &&
upgradeProgress[node.id].percent < 100 && (
<div className="mt-1">
<Progress
showValueLabel
aria-label="升级进度"
color="warning"
label={upgradeProgress[node.id].message}
size="sm"
value={upgradeProgress[node.id].percent}
<NodeUpgradeProgress
progress={upgradeProgress[node.id]}
/>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-default-600">
开机时间
+12 -109
View File
@@ -78,17 +78,19 @@ import {
} from "@/shadcn-bridge/heroui/dropdown";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
interface MonitorViewProps {
nodeMap: Map<
number,
{ id: number; name: string; connectionStatus: string; version?: string }
>;
realtimeNodeMetrics: Record<number, RealtimeNodeMetric>;
wsConnected: boolean;
wsConnecting: boolean;
viewMode?: "list" | "grid";
}
type RealtimeNodeMetric = {
export type RealtimeNodeMetric = {
receivedAt: number;
cpuUsage: number;
memoryUsage: number;
@@ -532,7 +534,13 @@ const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
maxTimeoutSec: 60,
};
export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
export function MonitorView({
nodeMap,
realtimeNodeMetrics,
wsConnected,
wsConnecting,
viewMode = "grid",
}: MonitorViewProps) {
const [detailNodeId, setDetailNodeId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [metrics, setMetrics] = useState<NodeMetricApiItem[]>([]);
@@ -582,27 +590,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const [resultsLimit, setResultsLimit] = useState(50);
const [resultsLoading, setResultsLoading] = useState(false);
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<
Record<number, "online" | "offline">
>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
Record<number, RealtimeNodeMetric>
>({});
const nodes = useMemo(() => {
return Array.from(nodeMap.values()).map((n) => {
const status = realtimeNodeStatus[n.id];
if (!status) {
return n;
}
return {
...n,
connectionStatus: status,
};
});
}, [nodeMap, realtimeNodeStatus]);
const nodes = useMemo(() => Array.from(nodeMap.values()), [nodeMap]);
const onlineNodes = nodes.filter((n) => n.connectionStatus === "online");
const preferredNodeId = onlineNodes[0]?.id ?? nodes[0]?.id ?? 0;
@@ -610,91 +598,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const resolvedServiceMonitorLimits =
serviceMonitorLimits ?? DEFAULT_SERVICE_MONITOR_LIMITS;
const handleRealtimeMessage = useCallback((message: any) => {
const nodeId = Number(message?.id ?? 0);
if (!nodeId || Number.isNaN(nodeId)) {
return;
}
const type = String(message?.type ?? "");
const payload = message?.data;
if (type === "status") {
const status = Number(payload);
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: status === 1 ? "online" : "offline",
}));
return;
}
if (type === "metric") {
let raw = payload;
if (typeof raw === "string") {
try {
raw = JSON.parse(raw);
} catch {
return;
}
}
if (!raw || typeof raw !== "object") {
return;
}
const metric = raw as Record<string, unknown>;
const receivedAt = Date.now();
const incomingUptime = Number(metric.uptime ?? 0);
setRealtimeNodeMetrics((prev) => {
const normalized: RealtimeNodeMetric = {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
netOutBytes: Number(
metric.netOutBytes ?? metric.bytes_transmitted ?? 0,
),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
load1: Number(metric.load1 ?? 0),
load5: Number(metric.load5 ?? 0),
load15: Number(metric.load15 ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: incomingUptime || prev[nodeId]?.uptime || 0,
};
return {
...prev,
[nodeId]: normalized,
};
});
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: "online",
}));
return;
}
if (type === "info") {
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: "online",
}));
}
}, []);
const { wsConnected, wsConnecting } = useNodeRealtime({
onMessage: handleRealtimeMessage,
enabled: !accessDenied,
});
useEffect(() => {
if (!selectedNodeId && preferredNodeId > 0) {
setSelectedNodeId(preferredNodeId);
@@ -0,0 +1,46 @@
import { Progress } from "@/shadcn-bridge/heroui/progress";
export interface NodeUpgradeProgressState {
stage: string;
percent: number;
message: string;
}
const STAGE_LABELS: Record<string, string> = {
downloading: "下载",
verifying: "校验",
installing: "安装 / 重启",
};
export function NodeUpgradeProgress({
progress,
}: {
progress?: NodeUpgradeProgressState;
}) {
// The final installing event means the agent is restarting, not a durable
// job state. Do not leave a stale "重启中 100%" banner after it reconnects.
if (
!progress ||
(progress.stage === "installing" && progress.percent >= 100)
) {
return null;
}
const percent = Number.isFinite(progress.percent)
? Math.min(100, Math.max(0, progress.percent))
: 0;
const stage = STAGE_LABELS[progress.stage];
return (
<div className="mt-2 min-w-40 max-w-xs break-words" role="status">
<Progress
showValueLabel
aria-label={stage ? `升级${stage}进度` : "升级进度"}
color="warning"
label={progress.message || (stage ? `正在${stage}` : "正在升级")}
size="sm"
value={percent}
/>
</div>
);
}
+39 -27
View File
@@ -64,7 +64,6 @@ import {
getNodeList,
diagnoseTunnel,
updateTunnelOrder,
batchRedeployTunnels,
previewBatchTunnelDelete,
previewTunnelDelete,
} from "@/api";
@@ -74,6 +73,11 @@ import {
getDiagnosisQualityDisplay,
type DiagnosisResult,
} from "@/pages/tunnel/diagnosis";
import { useTunnelRedeploy } from "@/pages/tunnel/use-tunnel-redeploy";
import {
TunnelRedeployStatus,
TunnelRedeploySummary,
} from "@/pages/tunnel/redeploy-progress";
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
import {
createTunnelFormDefaults,
@@ -455,6 +459,7 @@ export default function TunnelPage() {
number | null
>(null);
const [batchLoading, setBatchLoading] = useState(false);
const redeploy = useTunnelRedeploy();
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
@@ -1351,19 +1356,12 @@ export default function TunnelPage() {
};
const handleBatchRedeploy = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
percent: 30,
});
if (selectedIds.size === 0 || batchLoading || redeploy.state?.running)
return;
try {
const res = await batchRedeployTunnels(Array.from(selectedIds));
if (res.code === 0) {
const result = res.data;
const result = await redeploy.run(Array.from(selectedIds));
if (!result) return;
if (result.failCount === 0) {
toast.success(`成功重新下发 ${result.successCount} 项`);
} else {
@@ -1386,20 +1384,11 @@ export default function TunnelPage() {
}
setSelectedIds(new Set());
setSelectMode(false);
setBatchProgress({
active: true,
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 100,
});
await refreshTunnelList(false);
} else {
toast.error(res.msg || "下发失败");
}
} catch (error) {
toast.error(extractApiErrorMessage(error, "下发失败"));
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
toast.error(
extractApiErrorMessage(error, "下发进度中断,请检查结果后再重试"),
);
}
};
@@ -1707,7 +1696,11 @@ export default function TunnelPage() {
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
isDisabled={
selectedIds.size === 0 ||
batchLoading ||
redeploy.state?.running
}
size="sm"
variant="flat"
onPress={handleOpenBatchDeleteModal}
@@ -1716,8 +1709,12 @@ export default function TunnelPage() {
</Button>
<Button
color="primary"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
isDisabled={
selectedIds.size === 0 ||
batchLoading ||
redeploy.state?.running
}
isLoading={batchLoading || redeploy.state?.running}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
@@ -1789,6 +1786,11 @@ export default function TunnelPage() {
</div>
)}
<TunnelRedeploySummary
state={redeploy.state}
onDismiss={redeploy.clear}
/>
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
@@ -1860,6 +1862,9 @@ export default function TunnelPage() {
{tunnel.name}
</span>
</div>
<TunnelRedeployStatus
row={redeploy.state?.rows[tunnel.id]}
/>
</TableCell>
<TableCell>
<Chip
@@ -1918,6 +1923,7 @@ export default function TunnelPage() {
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
isDisabled={redeploy.state?.running}
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
@@ -1934,6 +1940,7 @@ export default function TunnelPage() {
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
isDisabled={redeploy.state?.running}
size="sm"
variant="flat"
onPress={() => handleDelete(tunnel)}
@@ -2015,6 +2022,9 @@ export default function TunnelPage() {
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<TunnelRedeployStatus
row={redeploy.state?.rows[tunnel.id]}
/>
<div className="space-y-3">
{/* 拓扑结构 */}
<div className="pt-2 border-t border-divider">
@@ -2160,6 +2170,7 @@ export default function TunnelPage() {
<Button
className="flex-1 min-h-8"
color="primary"
isDisabled={redeploy.state?.running}
size="sm"
startContent={
<svg
@@ -2202,6 +2213,7 @@ export default function TunnelPage() {
<Button
className="flex-1 min-h-8"
color="danger"
isDisabled={redeploy.state?.running}
size="sm"
startContent={
<svg
@@ -0,0 +1,97 @@
import type {
TunnelRedeployRow,
TunnelRedeployState,
} from "./use-tunnel-redeploy";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
export function TunnelRedeployStatus({ row }: { row?: TunnelRedeployRow }) {
if (!row) return null;
const text = {
queued: "等待下发",
running: "正在下发配置…",
success: "配置下发成功",
failed: "配置下发失败",
unknown: "下发结果未确认",
}[row.status];
const color =
row.status === "failed"
? "text-danger"
: row.status === "success"
? "text-success"
: row.status === "unknown"
? "text-warning"
: "text-default-500";
return (
<div className={`mt-1 flex items-start gap-1.5 text-xs ${color}`}>
{row.status === "running" && <Spinner size="sm" />}
<span className="max-w-xs break-words" title={row.reason}>
{text}
{row.reason ? `:${row.reason}` : ""}
</span>
</div>
);
}
export function TunnelRedeploySummary({
state,
onDismiss,
}: {
state: TunnelRedeployState | null;
onDismiss: () => void;
}) {
if (!state) return null;
const { progress, running, error, currentName } = state;
const label = `${error ? "已确认" : "已完成"} ${progress.completed}/${progress.total} 条,成功 ${progress.success} 条,失败 ${progress.failed} 条`;
const percent =
progress.total > 0 ? (progress.completed / progress.total) * 100 : 0;
return (
<div
className="mb-4 space-y-2 rounded-xl border border-divider bg-content1 p-3"
role="status"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1 break-words text-sm">
<p className="font-medium">
{running
? "正在下发隧道配置"
: error
? "下发进度已中断"
: "隧道配置下发结束"}
</p>
<p className="text-default-500">{label}</p>
{currentName && <p>当前:{currentName}</p>}
{running && (
<p className="text-xs text-default-500">
进度按已完成的隧道统计,当前隧道内部步骤不计百分比。请保留此页面。
</p>
)}
{error && <p className="text-warning">{error}</p>}
</div>
{!running && (
<Button
aria-label="关闭下发结果"
size="sm"
variant="light"
onPress={onDismiss}
>
关闭
</Button>
)}
</div>
<Progress
showValueLabel
aria-label="隧道配置下发进度"
color={error || progress.failed > 0 ? "warning" : "primary"}
size="sm"
value={percent}
/>
</div>
);
}
@@ -0,0 +1,118 @@
import { useEffect, useRef, useState } from "react";
import {
batchRedeployTunnelsStream,
type TunnelRedeployProgress,
} from "@/api/tunnel-redeploy-stream";
import { extractApiErrorMessage } from "@/api/error-message";
export interface TunnelRedeployRow {
status: "queued" | "running" | "success" | "failed" | "unknown";
reason?: string;
}
export interface TunnelRedeployState {
running: boolean;
progress: TunnelRedeployProgress;
rows: Record<number, TunnelRedeployRow>;
currentName?: string;
error?: string;
}
export function useTunnelRedeploy() {
const [state, setState] = useState<TunnelRedeployState | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const run = async (ids: number[]) => {
// Guard synchronously, before React has rendered a disabled button.
if (abortRef.current || ids.length === 0) return null;
const controller = new AbortController();
abortRef.current = controller;
setState({
running: true,
progress: { total: ids.length, completed: 0, success: 0, failed: 0 },
rows: Object.fromEntries(ids.map((id) => [id, { status: "queued" }])),
});
try {
const result = await batchRedeployTunnelsStream(
ids,
{
onStart: (progress) => {
setState((prev) => prev && { ...prev, progress });
},
onProcessing: ({ id, name, progress }) => {
setState(
(prev) =>
prev && {
...prev,
progress,
currentName: name || `隧道 #${id}`,
rows: { ...prev.rows, [id]: { status: "running" } },
},
);
},
onItem: ({ result: item, progress }) => {
setState(
(prev) =>
prev && {
...prev,
progress,
currentName: undefined,
rows: {
...prev.rows,
[item.id]: {
status: item.success ? "success" : "failed",
reason: item.reason,
},
},
},
);
},
},
controller.signal,
);
if (controller.signal.aborted) return null;
setState(
(prev) => prev && { ...prev, running: false, currentName: undefined },
);
return result;
} catch (error) {
if (controller.signal.aborted) return null;
setState(
(prev) =>
prev && {
...prev,
running: false,
currentName: undefined,
error: extractApiErrorMessage(
error,
"下发进度中断,请检查未确认的结果后再重试",
),
rows: Object.fromEntries(
Object.entries(prev.rows).map(([id, row]) => [
id,
row.status === "queued" || row.status === "running"
? { status: "unknown" }
: row,
]),
),
},
);
throw error;
} finally {
if (abortRef.current === controller) abortRef.current = null;
}
};
const clear = () => {
if (!abortRef.current) setState(null);
};
return { state, run, clear };
}
+125 -124
View File
@@ -1,13 +1,18 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import { LayoutGrid, List } from "lucide-react";
import { isAdmin } from "@/utils/auth";
import { useServerOrder } from "@/hooks/use-server-order";
import { getOrderDisabledReason } from "@/utils/server-order";
import {
AnimatedPage,
StaggerList,
StaggerItem,
} from "@/components/animated-page";
OrderHandle,
OrderItem,
OrderList,
OrderStatus,
} from "@/components/server-order";
import { AnimatedPage, StaggerList } from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -43,10 +48,10 @@ import {
TunnelAssignItem,
Tunnel,
SpeedLimit,
Pagination as PaginationType,
} from "@/types";
import {
getAllUsers,
updateUserOrder,
createUser,
updateUser,
deleteUser,
@@ -145,8 +150,6 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
return inFlow + outFlow;
};
const USER_SEARCH_DEBOUNCE_MS = 250;
const normalizeUserItem = (item: Partial<User>): User => {
return {
id: Number(item.id ?? 0),
@@ -191,10 +194,23 @@ const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
};
};
const fetchOrderedUsers = async (): Promise<User[]> => {
const response = await getAllUsers();
if (response.code !== 0) throw new Error(response.msg);
return (response.data || []).map((item) => normalizeUserItem(item));
};
export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const order = useServerOrder(fetchOrderedUsers, updateUserOrder);
const {
items: allUsers,
setItems: setUsers,
loading,
reload: loadUsers,
} = order;
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"user-search-keyword",
"",
@@ -204,12 +220,18 @@ export default function UserPage() {
"user-view-mode",
"grid",
);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
size: 10,
total: 0,
});
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const users = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
return keyword
? allUsers.filter(
(user) =>
user.user.trim().toLowerCase().includes(keyword) ||
(user.name || "").trim().toLowerCase().includes(keyword),
)
: allUsers;
}, [allUsers, searchKeyword]);
const [deleteLoading, setDeleteLoading] = useState(false);
// 用户表单相关状态
const {
@@ -359,37 +381,6 @@ export default function UserPage() {
return !speedLimitIds.has(speedId);
};
// 数据加载函数
const loadUsers = useCallback(
async (keywordOverride?: string) => {
setLoading(true);
try {
const keyword = keywordOverride ?? searchKeyword;
const response = await getAllUsers({
current: pagination.current,
size: pagination.size,
keyword,
});
if (response.code === 0) {
const nextUsers = Array.isArray(response.data)
? response.data.map((item) => normalizeUserItem(item))
: [];
setUsers(nextUsers);
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
} else {
toast.error(response.msg || "获取用户列表失败");
}
} catch {
toast.error("获取用户列表失败");
} finally {
setLoading(false);
}
},
[pagination.current, pagination.size],
);
const loadTunnels = useCallback(async () => {
try {
const response = await getTunnelList();
@@ -483,40 +474,10 @@ export default function UserPage() {
void loadMonitorPermissions();
}, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
searchDebounceRef.current = setTimeout(() => {
setPagination((prev) => {
if (prev.current === 1) {
void loadUsers(searchKeyword);
return prev;
}
return { ...prev, current: 1 };
});
}, USER_SEARCH_DEBOUNCE_MS);
return () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
};
}, [loadUsers, searchKeyword]);
// 用户管理操作
// Search is local to the complete server snapshot, so no partial response
// can replace the reorder set when a search is cleared.
const handleSearch = () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
setPagination((prev) => ({ ...prev, current: 1 }));
void loadUsers(searchKeyword);
void loadUsers();
};
const handleAdd = () => {
@@ -581,7 +542,8 @@ export default function UserPage() {
};
const handleConfirmDelete = async () => {
if (!userToDelete) return;
if (!userToDelete || order.saving || deleteLoading) return;
setDeleteLoading(true);
try {
const response = await deleteUser(userToDelete.id);
@@ -589,11 +551,7 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("删除成功");
onDeleteModalClose();
setUsers((prev) => removeItemsById(prev, [userToDelete.id]));
setPagination((prev) => ({
...prev,
total: Math.max(prev.total - 1, 0),
}));
await loadUsers();
setUserToDelete(null);
if (currentUser?.id === userToDelete.id) {
setCurrentUser(null);
@@ -604,10 +562,13 @@ export default function UserPage() {
}
} catch {
toast.error("删除失败");
} finally {
setDeleteLoading(false);
}
};
const handleSubmitUser = async () => {
if (order.saving) return;
if (!userForm.user || (!userForm.pwd && !isEdit) || !userForm.expTime) {
toast.error("请填写完整信息");
@@ -645,26 +606,7 @@ export default function UserPage() {
if (response.code === 0) {
toast.success(isEdit ? "更新成功" : "创建成功");
onUserModalClose();
const responseUser = normalizeUserItem((response as any).data || {});
if (
isEdit &&
responseUser.id > 0 &&
pagination.current === 1 &&
!searchKeyword.trim()
) {
setUsers((prev) => replaceItemById(prev, responseUser));
} else if (
!isEdit &&
responseUser.id > 0 &&
pagination.current === 1 &&
!searchKeyword.trim()
) {
setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));
setPagination((prev) => ({ ...prev, total: prev.total + 1 }));
} else {
await loadUsers();
}
} else {
const userDisabled =
isEdit &&
@@ -958,7 +900,7 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("用户配额已重置");
await loadUsers(searchKeyword);
await loadUsers();
} else {
toast.error(response.msg || "重置用户配额失败");
}
@@ -1048,6 +990,14 @@ export default function UserPage() {
return userTunnels.some((ut) => ut.tunnelId === tunnelId);
};
const orderReason = getOrderDisabledReason({
isAdmin: isAdmin(),
searchKeyword,
saving: order.saving,
busy: loading || userFormLoading || deleteLoading,
});
const orderDisabled = Boolean(orderReason);
return (
<AnimatedPage className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
@@ -1130,6 +1080,21 @@ export default function UserPage() {
</div>
{/* 用户列表 */}
<OrderStatus
error={order.error}
reason={orderReason}
retry={() => {
if (!order.saving) void loadUsers();
}}
/>
<OrderList
key={`${viewMode}:${searchKeyword}:${orderDisabled}`}
ids={users.map((user) => user.id)}
onDragEnd={(event) => {
void order.reorder(event, orderDisabled);
}}
onDragStart={order.onDragStart}
>
{loading ? (
<PageLoadingState message="正在加载..." />
) : users.length === 0 ? (
@@ -1173,9 +1138,16 @@ export default function UserPage() {
const usedFlow = calculateUserTotalUsedFlow(user);
return (
<TableRow key={user.id}>
<OrderItem
key={user.id}
row
disabled={orderDisabled}
id={user.id}
label={user.name || user.user}
>
<TableCell>
<div className="flex items-center gap-3">
<OrderHandle />
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()}
<span
@@ -1224,14 +1196,18 @@ export default function UserPage() {
(user.monthlyQuotaGB ?? 0) > 0 ? (
<>
<div className="flex gap-1 justify-between">
<span className="text-default-500">日配额:</span>
<span className="text-default-500">
日配额:
</span>
<span>
{formatFlow(user.dailyUsedBytes ?? 0)} /{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex gap-1 justify-between">
<span className="text-default-500">月配额:</span>
<span className="text-default-500">
月配额:
</span>
<span>
{formatFlow(user.monthlyUsedBytes ?? 0)} /{" "}
{formatQuotaLimit(user.monthlyQuotaGB)}
@@ -1308,7 +1284,7 @@ export default function UserPage() {
</Button>
</div>
</TableCell>
</TableRow>
</OrderItem>
);
}}
</TableBody>
@@ -1325,33 +1301,45 @@ export default function UserPage() {
const flowPercent =
user.flow > 0 && !(user.flow === 99999 && !user.flowMiB)
? Math.min(
(usedFlow / flowLimitBytes(user.flow, user.flowMiB)) * 100,
(usedFlow / flowLimitBytes(user.flow, user.flowMiB)) *
100,
100,
)
: 0;
return (
<StaggerItem key={user.id}>
<OrderItem
key={user.id}
disabled={orderDisabled}
id={user.id}
label={user.name || user.user}
>
<Card className="overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex flex-wrap items-start gap-2 w-full">
<div className="flex items-center gap-3 grow basis-44 min-w-0">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()}
<span
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
/>
</div>
<div className="flex flex-col min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">
<div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center gap-1 min-w-0">
<OrderHandle />
<h3
className="font-semibold text-foreground truncate text-sm"
title={user.name || user.user}
>
{user.name || user.user}
</h3>
</div>
<p className="text-xs text-default-500 truncate">
@{user.user}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 ml-2">
<div className="flex flex-wrap items-center gap-1.5 ml-auto max-w-full shrink-0">
<Chip
className="text-xs"
color={userStatus.color}
@@ -1412,16 +1400,22 @@ export default function UserPage() {
(user.disabledByQuota ?? 0) > 0 ? (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">每日配额</span>
<span className="text-default-600">
每日配额
</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
{formatFlow(Number(user.dailyUsedBytes ?? 0))}{" "}
/ {formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">每月配额</span>
<span className="text-default-600">
每月配额
</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.monthlyUsedBytes ?? 0))}{" "}
{formatFlow(
Number(user.monthlyUsedBytes ?? 0),
)}{" "}
/ {formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
@@ -1529,11 +1523,12 @@ export default function UserPage() {
</div>
</CardBody>
</Card>
</StaggerItem>
</OrderItem>
);
})}
</StaggerList>
)}
</OrderList>
{/* 用户表单模态框 */}
<Modal
@@ -1784,6 +1779,7 @@ export default function UserPage() {
<Button onPress={onUserModalClose}>取消</Button>
<Button
color="primary"
isDisabled={order.saving}
isLoading={userFormLoading}
onPress={handleSubmitUser}
>
@@ -2347,7 +2343,12 @@ export default function UserPage() {
<Button variant="light" onPress={onDeleteModalClose}>
取消
</Button>
<Button color="danger" onPress={handleConfirmDelete}>
<Button
color="danger"
isDisabled={order.saving}
isLoading={deleteLoading}
onPress={handleConfirmDelete}
>
确认删除
</Button>
</ModalFooter>
+20
View File
@@ -0,0 +1,20 @@
interface OrderAvailability {
isAdmin: boolean;
searchKeyword: string;
saving: boolean;
busy: boolean;
}
export function getOrderDisabledReason({
isAdmin,
searchKeyword,
saving,
busy,
}: OrderAvailability): string {
if (!isAdmin) return "仅管理员可调整全局顺序。";
if (searchKeyword.length > 0) return "搜索期间无法排序,请清空搜索后重试。";
if (saving) return "正在保存顺序,请稍候。";
if (busy) return "正在加载或更新列表,请稍候。";
return "";
}
@@ -0,0 +1,206 @@
import type {
SystemUpgradeJob,
SystemUpgradeRunApiData,
SystemUpgradeStatusApiData,
} from "@/api/types";
type Response<T> = {
code: number;
data: T;
msg?: string;
transportError?: boolean;
};
export interface UpgradeMonitorState {
job: SystemUpgradeJob | null;
currentVersion: string;
checking: boolean;
submitting: boolean;
uncertain: boolean;
paused: boolean;
notice: string;
}
export const initialUpgradeState: UpgradeMonitorState = {
job: null,
currentVersion: "",
checking: true,
submitting: false,
uncertain: true,
paused: false,
notice: "",
};
export const upgradeIsBlocked = (state: UpgradeMonitorState) =>
state.checking ||
state.submitting ||
state.uncertain ||
Boolean(
state.job?.active ||
state.job?.requiresRecovery ||
state.job?.stage === "recovery_required",
);
// The server supplies sanitized public summaries, never helper logs. Render as
// text only; remove control characters and cap length as defense in depth.
export const upgradeMessage = (message: string) =>
message.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 500);
// Kept independent of React so lifecycle/disconnect races can be replayed
// without adding frontend test infrastructure.
export function createUpgradeMonitor(
api: {
status: (id?: string) => Promise<Response<SystemUpgradeStatusApiData>>;
run: (
version?: string,
channel?: "stable" | "dev",
) => Promise<Response<SystemUpgradeRunApiData>>;
},
publish: (state: UpgradeMonitorState) => void,
clock = {
now: () => Date.now(),
schedule: (fn: () => void, ms: number) => setTimeout(fn, ms),
cancel: (timer: ReturnType<typeof setTimeout>) => clearTimeout(timer),
},
) {
let state = { ...initialUpgradeState };
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
let disposed = false;
let busy = false;
let deadline = 0;
let failures = 0;
let expectedJob: string | undefined;
let awaitingSubmission = false;
let previousJob: string | undefined;
const emit = (patch: Partial<UpgradeMonitorState>) => {
state = { ...state, ...patch };
if (!disposed) publish(state);
};
const cancelTimer = () => {
if (timer !== undefined) clock.cancel(timer);
timer = undefined;
};
const poll = async () => {
if (disposed || busy) return;
busy = true;
const request = generation;
try {
const response = await api.status(expectedJob);
if (disposed || request !== generation) return;
if (response.code !== 0 || !response.data) throw new Error("unavailable");
const { job, currentVersion } = response.data;
// An accepted or interrupted POST must not be mistaken for an idle
// server, nor may an older completed job certify the new request.
if (
(expectedJob && job?.id !== expectedJob) ||
(awaitingSubmission && (!job || job.id === previousJob))
) {
throw new Error("awaiting job");
}
failures = 0;
awaitingSubmission = false;
expectedJob = job?.id;
emit({
job,
currentVersion,
checking: false,
uncertain: false,
paused: false,
});
} catch {
if (disposed || request !== generation) return;
failures += 1;
// Disconnection is not proof of failure. Keep the last known job and
// continue blocking submissions until authoritative status is available.
emit({ checking: false, uncertain: true });
} finally {
busy = false;
if (
!disposed &&
request === generation &&
(state.uncertain || state.job?.active)
) {
if (clock.now() >= deadline) {
emit({ paused: true });
} else {
timer = clock.schedule(
() => {
void poll();
},
Math.min(15000, 2000 * 2 ** Math.min(failures, 3)),
);
}
}
}
};
return {
refresh() {
if (disposed || busy || state.submitting) return;
cancelTimer();
if (
!state.uncertain &&
!state.job?.active &&
!state.job?.requiresRecovery
) {
expectedJob = undefined;
}
deadline = clock.now() + 15 * 60 * 1000;
failures = 0;
emit({ checking: true, paused: false });
void poll();
},
async submit(version?: string, channel?: "stable" | "dev") {
if (disposed || busy || upgradeIsBlocked(state)) return false;
cancelTimer();
const request = ++generation;
previousJob = state.job?.id;
expectedJob = undefined;
awaitingSubmission = true;
emit({
job: null,
submitting: true,
uncertain: true,
paused: false,
notice: "",
});
try {
const response = await api.run(version, channel);
if (disposed || request !== generation) return false;
if (response.code === 0 && response.data?.jobId) {
expectedJob = response.data.jobId;
awaitingSubmission = false;
} else if (response.code !== 0 && !response.transportError) {
// Definite server rejection: rediscover the authoritative job,
// including concurrent submissions from other admin sessions.
awaitingSubmission = false;
emit({
notice: upgradeMessage(
response.msg || "升级请求被拒绝,请检查当前任务状态。",
),
});
}
} catch {
// The POST may already have started the helper. Reconcile, don't retry.
} finally {
if (!disposed && request === generation) {
emit({ submitting: false });
deadline = clock.now() + 15 * 60 * 1000;
void poll();
}
}
return !disposed && request === generation;
},
dispose() {
disposed = true;
generation += 1;
cancelTimer();
},
};
}