Compare commits

...

4 Commits

Author SHA1 Message Date
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
38 changed files with 4832 additions and 1364 deletions
+8
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
+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)
@@ -247,6 +248,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)
@@ -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{
Version: version,
Channel: channel,
ComposeAsset: composeAsset,
HelperContainer: helperContainer,
BackendImageID: imageID,
Message: systemUpgradeMessage,
}))
response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.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
+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
@@ -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")
}
}
+20 -16
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,14 +152,15 @@ type NftCounterState struct {
func (NftCounterState) TableName() string { return "nft_counter_state" }
type SpeedLimit struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Speed int `gorm:"not null"`
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
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"`
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
}
func (SpeedLimit) TableName() string { return "speed_limit" }
@@ -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,14 +543,15 @@ type UserTunnelBackup struct {
}
type SpeedLimitBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Speed int64 `json:"speed"`
TunnelID *int64 `json:"tunnelId,omitempty"`
TunnelName string `json:"tunnelName,omitempty"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
DisplayOrder *int64 `json:"displayOrder,omitempty"`
ID int64 `json:"id"`
Name string `json:"name"`
Speed int64 `json:"speed"`
TunnelID *int64 `json:"tunnelId,omitempty"`
TunnelName string `json:"tunnelName,omitempty"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
}
type TunnelGroupBackup struct {
+47 -17
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,7 +2093,8 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
out := make([]model.UserBackup, 0, len(users))
for _, u := range users {
b := model.UserBackup{
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
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,
CreatedTime: u.CreatedTime, Status: u.Status,
@@ -2289,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
out := make([]model.SpeedLimitBackup, 0, len(sls))
for _, sl := range sls {
b := model.SpeedLimitBackup{
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
DisplayOrder: &sl.DisplayOrder,
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
CreatedTime: sl.CreatedTime, Status: sl.Status,
}
if sl.UpdatedTime.Valid {
@@ -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",
}),
Columns: []clause.Column{{Name: "id"}},
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",
}),
Columns: []clause.Column{{Name: "id"}},
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
@@ -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,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)
}
})
}
})
}
}
@@ -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)
}
}
@@ -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)
}
}
}
+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.
+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,
});
+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="shrink-0 rounded p-1 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"
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),
};
}
+125 -28
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(
systemUpgradeSelectedVersion || undefined,
updateChannel,
);
if (!canTriggerSystemUpgrade) return;
const submitted = await upgrade.submit(
systemUpgradeSelectedVersion || undefined,
updateChannel,
);
if (response.code === 0 && response.data) {
const data = response.data as SystemUpgradeRunApiData;
setSystemUpgradeModalOpen(false);
setSystemUpgradeSelectedVersion("");
toast.success(data.message || "升级已触发,请稍后刷新页面");
} else {
toast.error(response.msg || "面板升级失败");
}
} catch {
toast.error("面板升级失败,请重试");
} finally {
setSystemUpgradeExecuting(false);
if (submitted) {
setSystemUpgradeModalOpen(false);
setSystemUpgradeSelectedVersion("");
}
};
@@ -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}
>
确认升级
+215 -187
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,172 +258,203 @@ export default function LimitPage() {
</div>
</div>
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
<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}
>
{/* 统一卡片网格 */}
{loading ? (
<PageLoadingState message="正在加载..." />
) : filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<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"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<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"
</TableCell>
<TableCell>
<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"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</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"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</OrderItem>
)}
</TableBody>
</Table>
</Card>
) : (
<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) => (
<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">
<OrderHandle />
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</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"
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</CardBody>
</Card>
</OrderItem>
))}
</StaggerList>
)
) : (
<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}>
<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">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</CardBody>
</Card>
</StaggerItem>
))}
</StaggerList>
)
) : (
/* 空状态 */
<Card className="shadow-sm bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无限速规则
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
/* 空状态 */
<Card className="shadow-sm bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无限速规则
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
</OrderList>
{/* 新增/编辑模态框 */}
<Modal
@@ -497,6 +523,7 @@ export default function LimitPage() {
</Button>
<Button
color="primary"
isDisabled={order.saving}
isLoading={submitLoading}
onPress={handleSubmit}
>
@@ -544,6 +571,7 @@ export default function LimitPage() {
</Button>
<Button
color="danger"
isDisabled={order.saving}
isLoading={deleteLoading}
onPress={confirmDelete}
>
+113 -57
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,73 +45,117 @@ 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 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 any;
setRealtimeNodeMetrics((prev) => ({
...prev,
[nodeId]: {
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
},
}));
}
const updateNodeStatus = useCallback((nodeId: number, status: number) => {
realtimeNodeStatusRef.current.set(nodeId, { status });
setNodes((prev) =>
prev.map((node) =>
Number(node.id) === nodeId ? { ...node, status } : node,
),
);
}, []);
useNodeRealtime({
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") {
updateNodeStatus(nodeId, Number(payload) === 1 ? 1 : 0);
return;
}
if (type === "info") {
updateNodeStatus(nodeId, 1);
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) => ({
...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,
),
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],
);
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} />
)}
+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);
File diff suppressed because it is too large Load Diff
+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();
},
};
}