From 1c3217f72c0d45517cabd5581f45b12ec076a229 Mon Sep 17 00:00:00 2001 From: imengying Date: Thu, 10 Sep 2026 17:19:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=86=85=E5=AD=98=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + agent/agent.sh | 49 +++--- agent/install-freebsd.sh | 24 ++- agent/install-macos.sh | 22 ++- agent/install.ps1 | 76 ++++++--- agent/src/main.rs | 126 ++++++++++++-- frontend/src/install.test.ts | 11 +- frontend/src/updateSafety.test.ts | 274 ++++++++++++++++++++++++++++++ install.ps1 | 70 ++++++-- install.sh | 64 ++++--- 10 files changed, 595 insertions(+), 123 deletions(-) create mode 100644 frontend/src/updateSafety.test.ts diff --git a/README.md b/README.md index c1449f7..6750fad 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ NodeFlare 是一款轻量级的自托管服务器监控面板,通过 Web 界 CPU、内存和网速每秒采样,默认每 3 秒压缩批量上传。服务器卡片每秒显示一个真实采样值,正常连接下有约 2~3 秒的显示延迟;断线重连后跳过积压的旧数据。磁盘容量、GPU 等较慢指标单独缓存,历史数据按节点设置的保存间隔聚合。增大实时上传间隔会降低卡片连续更新的频率。 +Linux 内存统计与 Komari 默认口径一致:已用内存为 `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`,Swap 已用量扣除 `SwapCached`。文件缓存不计入已用内存,共享内存计入;面板显示的是整机内存,不是 NodeFlare 进程本身的占用。 + ## 安装服务端 支持 Linux x64/ARM64、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。安装脚本自动下载最新 Release 并注册系统服务。 diff --git a/agent/agent.sh b/agent/agent.sh index 601f7eb..f5b3a4e 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -62,28 +62,25 @@ cleanup_agent_install() { if [ "$rollback_agent" = true ]; then rollback_agent=false log "安装未完成,正在恢复上一版本" - case "$init_system" in - systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;; - openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;; - esac + stop_agent || { printf '错误:旧服务未停止,回滚备份已保留在 %s\n' "$STATE_DIR" >&2; return 1; } if [ "$had_agent" = true ]; then - cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true + cp -p "$backup_agent" "$AGENT_FILE" || return 1 else - rm -f "$AGENT_FILE" + rm -f "$AGENT_FILE" || return 1 fi service_path=$SERVICE_FILE [ "$init_system" != openrc ] || service_path=$OPENRC_FILE if [ "$had_service" = true ]; then - cp -p "$backup_service" "$service_path" 2>/dev/null || true + cp -p "$backup_service" "$service_path" || return 1 else - rm -f "$service_path" + rm -f "$service_path" || return 1 fi case "$init_system" in systemd) - systemctl daemon-reload 2>/dev/null || true - [ "$had_agent" != true ] || systemctl restart "$SERVICE_NAME" 2>/dev/null || true + systemctl daemon-reload || return 1 + [ "$had_service" != true ] || systemctl restart "$SERVICE_NAME" || return 1 ;; - openrc) [ "$had_agent" != true ] || rc-service "$SERVICE_NAME" restart 2>/dev/null || true ;; + openrc) [ "$had_service" != true ] || rc-service "$SERVICE_NAME" restart || return 1 ;; esac fi [ -z "$temporary" ] || rm -f "$temporary" @@ -143,6 +140,22 @@ ensure_not_agent_service() { done < "/proc/$$/cgroup" } +stop_agent() { + case "$init_system" in + systemd) + [ -f "$SERVICE_FILE" ] || return 0 + systemctl stop "$SERVICE_NAME" || return 1 + stopped_pid=$(systemctl show -p MainPID --value "$SERVICE_NAME") || return 1 + [ "$stopped_pid" = 0 ] + ;; + openrc) + [ -f "$OPENRC_FILE" ] || return 0 + rc-service "$SERVICE_NAME" stop || return 1 + ! rc-service "$SERVICE_NAME" status >/dev/null 2>&1 + ;; + esac +} + verify_agent_started() { started_pid=$(systemctl show -p MainPID --value "$SERVICE_NAME") || return 1 case "$started_pid" in ''|*[!0-9]*|0) return 1 ;; esac @@ -329,19 +342,8 @@ install_agent() { cp -p "$service_path" "$backup_service" had_service=true fi + stop_agent || fail "无法停止已有 Agent 服务,未替换程序" rollback_agent=true - case "$init_system" in - systemd) - systemctl stop "$SERVICE_NAME" 2>/dev/null || { - [ "$had_service" = false ] || fail "无法停止已有 Agent 服务,未替换程序" - } - ;; - openrc) - rc-service "$SERVICE_NAME" stop 2>/dev/null || { - [ "$had_service" = false ] || fail "无法停止已有 Agent 服务,未替换程序" - } - ;; - esac mv "$temporary" "$AGENT_FILE" log "正在启动 $init_system 服务" if [ "$init_system" = "systemd" ]; then @@ -467,6 +469,7 @@ uninstall_agent() { log "正在停止并移除 Agent 服务" init_system=$(detect_init_system) ensure_not_agent_service + stop_agent || fail "无法停止已有 Agent 服务,已取消卸载" case "$init_system" in systemd) systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true ;; openrc) diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index 532064e..991a6db 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -39,17 +39,17 @@ cleanup_agent_install() { if [ "$rollback_agent" = true ]; then rollback_agent=false log "安装未完成,正在恢复上一版本" - service "$SERVICE_NAME" stop 2>/dev/null || true + stop_agent || { printf '错误:旧服务未停止,回滚备份已保留在 %s\n' "$STATE_DIR" >&2; return 1; } if [ "$had_agent" = true ]; then - cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true + cp -p "$backup_agent" "$AGENT_FILE" || return 1 else - rm -f "$AGENT_FILE" + rm -f "$AGENT_FILE" || return 1 fi if [ "$had_service" = true ]; then - cp -p "$backup_service" "$SERVICE_FILE" 2>/dev/null || true - service "$SERVICE_NAME" start 2>/dev/null || true + cp -p "$backup_service" "$SERVICE_FILE" || return 1 + service "$SERVICE_NAME" start || return 1 else - rm -f "$SERVICE_FILE" + rm -f "$SERVICE_FILE" || return 1 fi fi [ -z "$temporary" ] || rm -f "$temporary" @@ -57,6 +57,13 @@ cleanup_agent_install() { [ -z "$backup_service" ] || rm -f "$backup_service" } +stop_agent() { + [ -f "$SERVICE_FILE" ] || return 0 + service "$SERVICE_NAME" onestatus >/dev/null 2>&1 || return 0 + service "$SERVICE_NAME" onestop || return 1 + ! service "$SERVICE_NAME" onestatus >/dev/null 2>&1 +} + usage() { cat <<'EOF' NodeFlare Agent FreeBSD 安装脚本 @@ -269,10 +276,9 @@ if [ -f "$SERVICE_FILE" ]; then cp -p "$SERVICE_FILE" "$backup_service" had_service=true fi -rollback_agent=true - log "正在启动 FreeBSD rc.d 服务" -service "$SERVICE_NAME" stop 2>/dev/null || true +stop_agent || fail "无法停止已有 Agent 服务,未替换程序" +rollback_agent=true mv "$temporary" "$AGENT_FILE" cat > "$SERVICE_FILE" </dev/null || true + stop_agent || { printf '错误:旧服务未停止,回滚备份已保留在 %s\n' "$STATE_DIR" >&2; return 1; } if [ "$had_agent" = true ]; then - cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true + cp -p "$backup_agent" "$AGENT_FILE" || return 1 else - rm -f "$AGENT_FILE" + rm -f "$AGENT_FILE" || return 1 fi if [ "$had_service" = true ]; then - cp -p "$backup_service" "$PLIST_FILE" 2>/dev/null || true - launchctl bootstrap system "$PLIST_FILE" 2>/dev/null || true + cp -p "$backup_service" "$PLIST_FILE" || return 1 + launchctl bootstrap system "$PLIST_FILE" || return 1 else - rm -f "$PLIST_FILE" + rm -f "$PLIST_FILE" || return 1 fi fi [ -z "$temporary" ] || rm -f "$temporary" @@ -56,6 +56,12 @@ cleanup_agent_install() { [ -z "$backup_service" ] || rm -f "$backup_service" } +stop_agent() { + launchctl print "system/$LABEL" >/dev/null 2>&1 || return 0 + launchctl bootout system "$PLIST_FILE" || return 1 + ! launchctl print "system/$LABEL" >/dev/null 2>&1 +} + usage() { cat <<'EOF' NodeFlare Agent macOS 安装脚本 @@ -247,9 +253,9 @@ if [ -f "$PLIST_FILE" ]; then cp -p "$PLIST_FILE" "$backup_service" had_service=true fi -rollback_agent=true log "正在启动 macOS LaunchDaemon 服务" -launchctl bootout system "$PLIST_FILE" 2>/dev/null || true +stop_agent || fail "无法停止已有 Agent 服务,未替换程序" +rollback_agent=true mv "$temporary" "$AGENT_FILE" cat > "$PLIST_FILE" < diff --git a/agent/install.ps1 b/agent/install.ps1 index da4035c..712a883 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -32,6 +32,7 @@ $HadPreviousConfig = $false $HadPreviousLauncher = $false $PreviousTaskXml = $null $InstallChanged = $false +$KeepBackup = $false function Write-Step([string]$Message) { Write-Host $Message @@ -41,6 +42,19 @@ function Write-InstallError([string]$Message) { throw "错误:$Message" } +function Stop-Agent { + $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($null -ne $Task) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction Stop } + for ($Attempt = 0; $Attempt -lt 30; $Attempt++) { + $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + $Running = @(Get-CimInstance Win32_Process -Filter "Name = 'agent.exe'" -ErrorAction Stop | + Where-Object { $_.ExecutablePath -eq $AgentFile }) + if (($null -eq $Task -or $Task.State -ne "Running") -and $Running.Count -eq 0) { return } + Start-Sleep -Milliseconds 500 + } + Write-InstallError "旧 Agent 进程未退出,已停止操作,未替换程序" +} + function Show-InstallResult { Write-Host "" if ($Update -or $HadPreviousAgent) { @@ -116,7 +130,7 @@ function Assert-Mirror([string]$Value) { if ($Uninstall) { Write-Step "正在停止并移除 Agent 服务" - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Stop-Agent Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $LauncherFile -Force -ErrorAction SilentlyContinue @@ -214,8 +228,8 @@ try { if ($HadPreviousLauncher) { Copy-Item -LiteralPath $LauncherFile -Destination $PreviousLauncher -Force } $ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($null -ne $ExistingTask) { $PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName } + Stop-Agent $InstallChanged = $true - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force $AgentConfig = [ordered]@{ @@ -245,7 +259,7 @@ exit $LASTEXITCODE Write-Step "正在注册并启动 Windows 计划任务" $TaskAction = New-ScheduledTaskAction -Execute $PowerShell -Argument $TaskArguments $Trigger = New-ScheduledTaskTrigger -AtStartup - $Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650) + $Settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650) $Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null Start-ScheduledTask -TaskName $TaskName @@ -261,34 +275,42 @@ exit $LASTEXITCODE $InstallChanged = $false Show-InstallResult } catch { + $InstallError = $_ if ($InstallChanged) { Write-Warning "安装未完成,正在恢复上一版本" - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue - if ($HadPreviousAgent) { - Copy-Item -LiteralPath $PreviousAgent -Destination $AgentFile -Force - } else { - Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue - } - if ($HadPreviousConfig) { - Copy-Item -LiteralPath $PreviousConfig -Destination $ConfigFile -Force - } else { - Remove-Item -LiteralPath $ConfigFile -Force -ErrorAction SilentlyContinue - } - if ($HadPreviousLauncher) { - Copy-Item -LiteralPath $PreviousLauncher -Destination $LauncherFile -Force - } else { - Remove-Item -LiteralPath $LauncherFile -Force -ErrorAction SilentlyContinue - } - if ($null -ne $PreviousTaskXml) { - Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null - Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + try { + Stop-Agent + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + if ($HadPreviousAgent) { + Copy-Item -LiteralPath $PreviousAgent -Destination $AgentFile -Force + } else { + Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue + } + if ($HadPreviousConfig) { + Copy-Item -LiteralPath $PreviousConfig -Destination $ConfigFile -Force + } else { + Remove-Item -LiteralPath $ConfigFile -Force -ErrorAction SilentlyContinue + } + if ($HadPreviousLauncher) { + Copy-Item -LiteralPath $PreviousLauncher -Destination $LauncherFile -Force + } else { + Remove-Item -LiteralPath $LauncherFile -Force -ErrorAction SilentlyContinue + } + if ($null -ne $PreviousTaskXml) { + Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null + Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop + } + } catch { + $KeepBackup = $true + Write-Warning "自动回滚未完成,备份已保留:$PreviousAgent,$PreviousConfig,$PreviousLauncher;$_" } } - throw + throw $InstallError } finally { Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $PreviousAgent -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $PreviousConfig -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $PreviousLauncher -Force -ErrorAction SilentlyContinue + if (-not $KeepBackup) { + Remove-Item -LiteralPath $PreviousAgent -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $PreviousConfig -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $PreviousLauncher -Force -ErrorAction SilentlyContinue + } } diff --git a/agent/src/main.rs b/agent/src/main.rs index 2e38334..30decc6 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -679,9 +679,52 @@ fn mem_value(contents: &str, key: &str) -> i64 { .lines() .find(|line| line.starts_with(key)) .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|value| value.parse::().ok()) - .unwrap_or(0) - .saturating_mul(1024) + .and_then(|value| value.parse::().ok()) + .map_or(0, |value| u64_to_i64(value.saturating_mul(1024))) +} + +#[cfg(target_os = "linux")] +struct MemorySample { + total: i64, + used: i64, + swap_total: i64, + swap_used: i64, +} + +#[cfg(target_os = "linux")] +fn memory_sample(contents: &str) -> MemorySample { + let total = mem_value(contents, "MemTotal:"); + let free = mem_value(contents, "MemFree:"); + let reclaimable = free + .saturating_add(mem_value(contents, "Cached:")) + .saturating_add(mem_value(contents, "SReclaimable:")) + .saturating_add(mem_value(contents, "Buffers:")); + // Komari's default Linux/htop calculation excludes reclaimable caches but + // includes shared memory. MemAvailable uses a different kernel estimate. + let used = total + .saturating_sub(if reclaimable <= total { + reclaimable + } else { + free + }) + .saturating_add(mem_value(contents, "Shmem:")) + .clamp(0, total); + let swap_total = mem_value(contents, "SwapTotal:"); + let swap_free = mem_value(contents, "SwapFree:"); + let swap_deductions = swap_free.saturating_add(mem_value(contents, "SwapCached:")); + let swap_used = swap_total + .saturating_sub(if swap_deductions <= swap_total { + swap_deductions + } else { + swap_free + }) + .clamp(0, swap_total); + MemorySample { + total, + used, + swap_total, + swap_used, + } } #[cfg(target_os = "linux")] @@ -1402,7 +1445,6 @@ fn average_gpu_usage(gpus: &[GpuMetric]) -> f64 { } } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] fn u64_to_i64(value: u64) -> i64 { value.min(i64::MAX as u64) as i64 } @@ -1543,11 +1585,7 @@ impl Collector { } self.public_ip.refresh(); - let mem = text("/proc/meminfo"); - let mem_total = mem_value(&mem, "MemTotal:"); - let mem_used = mem_total.saturating_sub(mem_value(&mem, "MemAvailable:")); - let swap_total = mem_value(&mem, "SwapTotal:"); - let swap_used = swap_total.saturating_sub(mem_value(&mem, "SwapFree:")); + let memory = memory_sample(&text("/proc/meminfo")); let loads = text("/proc/loadavg") .split_whitespace() .take(3) @@ -1579,10 +1617,10 @@ impl Collector { load1: loads.first().copied().unwrap_or(0.0), load5: loads.get(1).copied().unwrap_or(0.0), load15: loads.get(2).copied().unwrap_or(0.0), - mem_used, - mem_total, - swap_used, - swap_total, + mem_used: memory.used, + mem_total: memory.total, + swap_used: memory.swap_used, + swap_total: memory.swap_total, disk_used, disk_total, net_in: per_second(io_now.rx.saturating_sub(io_before.rx), elapsed), @@ -1788,7 +1826,11 @@ impl Collector { load1: load.one, load5: load.five, load15: load.fifteen, - mem_used: u64_to_i64(self.system.used_memory()), + mem_used: u64_to_i64( + self.system + .total_memory() + .saturating_sub(self.system.available_memory()), + ), mem_total: u64_to_i64(self.system.total_memory()), swap_used: u64_to_i64(self.system.used_swap()), swap_total: u64_to_i64(self.system.total_swap()), @@ -3857,6 +3899,62 @@ mod tests { assert!(!super::excluded_filesystem("btrfs", "/var")); } + #[cfg(target_os = "linux")] + #[test] + fn memory_matches_komari_default_including_shared_memory_and_swap_cache() { + let memory = super::memory_sample( + "MemTotal: 1000000 kB\nMemFree: 100000 kB\nMemAvailable: 750000 kB\n\ + Cached: 300000 kB\nSReclaimable: 50000 kB\nBuffers: 25000 kB\n\ + Shmem: 25000 kB\nSwapTotal: 100000 kB\nSwapFree: 60000 kB\nSwapCached: 20000 kB\n", + ); + assert_eq!(memory.total, 1_000_000 * 1024); + assert_eq!(memory.used, 550_000 * 1024); + assert_eq!(memory.swap_total, 100_000 * 1024); + assert_eq!(memory.swap_used, 20_000 * 1024); + } + + #[cfg(target_os = "linux")] + #[test] + fn filling_file_cache_does_not_inflate_reported_memory() { + let before = super::memory_sample( + "MemTotal: 1000 kB\nMemFree: 400 kB\nCached: 300 kB\n\ + Buffers: 50 kB\nSReclaimable: 50 kB\nShmem: 100 kB\n", + ); + let after = super::memory_sample( + "MemTotal: 1000 kB\nMemFree: 200 kB\nCached: 500 kB\n\ + Buffers: 50 kB\nSReclaimable: 50 kB\nShmem: 100 kB\n", + ); + assert_eq!(before.used, 300 * 1024); + assert_eq!(after.used, before.used); + } + + #[cfg(target_os = "linux")] + #[test] + fn memory_handles_missing_and_inconsistent_kernel_counters() { + let memory = super::memory_sample( + "MemTotal: 1000 kB\nMemFree: 200 kB\nCached: 1000 kB\n\ + Shmem: 100 kB\nSwapTotal: 100 kB\nSwapFree: 20 kB\nSwapCached: 100 kB\n", + ); + assert_eq!(memory.used, 900 * 1024); + assert_eq!(memory.swap_used, 80 * 1024); + for contents in ["", "MemTotal: invalid kB\n", "MemTotal: -1 kB\n"] { + let memory = super::memory_sample(contents); + assert_eq!( + ( + memory.total, + memory.used, + memory.swap_total, + memory.swap_used + ), + (0, 0, 0, 0) + ); + } + let memory = super::memory_sample( + "MemTotal: 100 kB\nMemFree: 1000 kB\nSwapTotal: 100 kB\nSwapFree: 1000 kB\n", + ); + assert_eq!((memory.used, memory.swap_used), (0, 0)); + } + #[cfg(target_os = "linux")] #[test] fn reads_local_mounts_and_decodes_escaped_paths_once() { diff --git a/frontend/src/install.test.ts b/frontend/src/install.test.ts index ebb2242..4af24f0 100644 --- a/frontend/src/install.test.ts +++ b/frontend/src/install.test.ts @@ -98,6 +98,8 @@ describe("Windows uninstall ownership", () => { $ErrorActionPreference = "Stop" function Write-Step { } function Stop-ScheduledTask { } + function Stop-Server { } + function Stop-Agent { } function Unregister-ScheduledTask { } $Mode = "Uninstall" $Uninstall = $true @@ -466,11 +468,12 @@ describe("agent installer manual update", () => { describe("agent installer service safety", () => { test("a failed stop of an existing service prevents replacement", () => { - const stop = agentInstaller.slice(agentInstaller.indexOf(" rollback_agent=true\n"), agentInstaller.indexOf(' mv "$temporary" "$AGENT_FILE"')); - expect(stop).toContain("systemctl stop"); + const stop = agentInstaller.slice(agentInstaller.indexOf(" stop_agent || fail"), agentInstaller.indexOf(' mv "$temporary" "$AGENT_FILE"')); + expect(stop).toContain("stop_agent"); const directory = mkdtempSync(join(tmpdir(), "nodeflare-agent-stop-")); const log = join(directory, "stop.log"); try { + writeFileSync(log, "existing-service"); // Dash rejects hyphenated function names, so mock the external commands. for (const command of ["systemctl", "rc-service"]) { writeFileSync(join(directory, command), '#!/bin/sh\nprintf \'%s\\n\' "$*" > "$TEST_STOP_LOG"\nexit 1\n', { mode: 0o755 }); @@ -478,10 +481,12 @@ describe("agent installer service safety", () => { for (const init of ["systemd", "openrc"]) { const result = spawnSync("sh", ["-c", ` set -eu - ${agentShellFunctions("fail")} + ${agentShellFunctions("fail", "stop_agent")} init_system=$TEST_INIT had_service=true SERVICE_NAME=nodeflare-agent + SERVICE_FILE=$TEST_STOP_LOG + OPENRC_FILE=$TEST_STOP_LOG ${stop} printf 'replaced' `], { env: { ...process.env, PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, TEST_INIT: init, TEST_STOP_LOG: log }, encoding: "utf8" }); diff --git a/frontend/src/updateSafety.test.ts b/frontend/src/updateSafety.test.ts new file mode 100644 index 0000000..402706a --- /dev/null +++ b/frontend/src/updateSafety.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; + +const serverSource = readFileSync(new URL("../../install.sh", import.meta.url), "utf8"); + +function shellFunctions(source: string, ...names: string[]) { + return names.map((name) => { + const body = source.match(new RegExp(`^${name}\\(\\) \\{[\\s\\S]*?^\\}`, "m"))?.[0]; + if (!body) throw new Error(`Missing shell function: ${name}`); + return body; + }).join("\n"); +} + +function powershellFunction(source: string, name: string) { + const body = source.match(new RegExp(`^function ${name} \\{[\\s\\S]*?^\\}`, "m"))?.[0]; + if (!body) throw new Error(`Missing PowerShell function: ${name}`); + return body; +} + +describe("updates wait for the previous service to stop", () => { + const cases = [ + ["server systemd", "../../install.sh", "stop_server", "systemd"], + ["server OpenRC", "../../install.sh", "stop_server", "openrc"], + ["server launchd", "../../install.sh", "stop_server", "launchd"], + ["server FreeBSD", "../../install.sh", "stop_server", "freebsd"], + ["agent systemd", "../../agent/agent.sh", "stop_agent", "systemd"], + ["agent OpenRC", "../../agent/agent.sh", "stop_agent", "openrc"], + ["agent launchd", "../../agent/install-macos.sh", "stop_agent", "launchd"], + ["agent FreeBSD", "../../agent/install-freebsd.sh", "stop_agent", "freebsd"], + ] as const; + + for (const [label, path, stop, init] of cases) { + const source = readFileSync(new URL(path, import.meta.url), "utf8"); + for (const mode of ["stops", "stop-fails", "still-running"] as const) { + test(`${label}: ${mode}`, () => { + const directory = mkdtempSync(join(tmpdir(), "nodeflare-stop-test-")); + try { + const mock = `#!/bin/sh +set -eu +case "$0" in */systemctl|*/launchctl) action=$1 ;; *) action=$2 ;; esac +case "$action" in + show) if [ "$(cat "$TEST_STATE")" = running ]; then printf '123'; else printf '0'; fi ;; + status|onestatus|print) [ "$(cat "$TEST_STATE")" = running ] ;; + stop|onestop|bootout) + [ "$TEST_MODE" != stop-fails ] || exit 1 + if [ "$TEST_MODE" = stops ]; then printf stopped > "$TEST_STATE"; fi + ;; + *) exit 1 ;; +esac +`; + for (const command of ["systemctl", "rc-service", "launchctl", "service"]) { + writeFileSync(join(directory, command), mock, { mode: 0o755 }); + } + writeFileSync(join(directory, "state"), "running"); + writeFileSync(join(directory, "service-definition"), "existing-service"); + const result = spawnSync("sh", ["-c", ` + set -eu + ${shellFunctions(source, ...(stop === "stop_server" ? ["service_definition", stop] : [stop]))} + init_system=$TEST_INIT + SERVICE_NAME=nodeflare-agent + LABEL=nodeflare-agent + SERVICE_FILE=$TEST_DIRECTORY/service-definition + OPENRC_FILE=$SERVICE_FILE + PLIST_FILE=$SERVICE_FILE + systemd_file=$SERVICE_FILE + openrc_file=$SERVICE_FILE + launchd_file=$SERVICE_FILE + freebsd_rc_file=$SERVICE_FILE + if ${stop}; then printf replaced; else exit 1; fi + `], { + env: { ...process.env, PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, + TEST_DIRECTORY: directory, TEST_STATE: join(directory, "state"), TEST_MODE: mode, TEST_INIT: init }, + encoding: "utf8", timeout: 5000, + }); + expect(result.status).toBe(mode === "stops" ? 0 : 1); + expect(result.stdout).toBe(mode === "stops" ? "replaced" : ""); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } + } +}); + +describe("server update rollback ownership", () => { + for (const separateShare of [false, true]) { + test(`preserves newer Agent files during rollback (separate share=${separateShare})`, () => { + const directory = mkdtempSync(join(tmpdir(), "nodeflare-rollback-test-")); + const install = join(directory, "install"); + const share = separateShare ? join(directory, "share") : join(install, "share"); + try { + mkdirSync(share, { recursive: true }); + mkdirSync(install, { recursive: true }); + writeFileSync(join(install, "nodeflare"), "previous-server"); + writeFileSync(join(install, "agent"), "previous-agent"); + writeFileSync(join(share, "index.html"), "previous-frontend"); + writeFileSync(join(directory, "config.toml"), "server-config"); + writeFileSync(join(directory, "service"), "previous-service"); + const result = spawnSync("sh", ["-c", ` + set -eu + ${shellFunctions(serverSource, "log", "snapshot_install", "rollback_install", "service_definition")} + install_dir=$TEST_DIRECTORY/install + server_binary=$install_dir/nodeflare + share_dir=$TEST_SHARE + config_file=$TEST_DIRECTORY/config.toml + download_dir=$TEST_DIRECTORY/download + systemd_file=$TEST_DIRECTORY/service + init_system=systemd + previous_install=false + previous_share=false + previous_service=false + snapshot_install + printf newer-agent > "$install_dir/agent" + printf live-state > "$install_dir/pending.jsonl" + printf broken-server > "$server_binary" + printf broken-frontend > "$share_dir/index.html" + stop_server() { return 0; } + start_server() { return 0; } + rollback_install + `], { env: { ...process.env, TEST_DIRECTORY: directory, TEST_SHARE: share }, encoding: "utf8" }); + expect(result.status).toBe(0); + expect(readFileSync(join(install, "nodeflare"), "utf8")).toBe("previous-server"); + expect(readFileSync(join(share, "index.html"), "utf8")).toBe("previous-frontend"); + expect(readFileSync(join(install, "agent"), "utf8")).toBe("newer-agent"); + expect(readFileSync(join(install, "pending.jsonl"), "utf8")).toBe("live-state"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } + + test("failed rollback preserves its backup and does not overwrite a running service", () => { + const directory = mkdtempSync(join(tmpdir(), "nodeflare-rollback-failure-")); + try { + mkdirSync(join(directory, "download", "previous"), { recursive: true }); + writeFileSync(join(directory, "download", "previous", "nodeflare"), "previous-server"); + writeFileSync(join(directory, "nodeflare"), "running-server"); + const result = spawnSync("sh", ["-c", ` + set -eu + ${shellFunctions(serverSource, "cleanup", "rollback_install", "log")} + restore_tty() { return 0; } + stop_server() { return 1; } + rollback_ready=true + previous_install=true + server_binary=$TEST_DIRECTORY/nodeflare + config_temp="" + download_dir=$TEST_DIRECTORY/download + backup_dir=$download_dir/previous + cleanup + `], { env: { ...process.env, TEST_DIRECTORY: directory }, encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("备份已保留"); + expect(readFileSync(join(directory, "nodeflare"), "utf8")).toBe("running-server"); + expect(existsSync(join(directory, "download", "previous", "nodeflare"))).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); + +describe("Agent rollback backup retention", () => { + for (const path of ["../../agent/agent.sh", "../../agent/install-macos.sh", "../../agent/install-freebsd.sh"]) { + test(`${path} preserves backups when restoring a file fails`, () => { + const directory = mkdtempSync(join(tmpdir(), "nodeflare-agent-rollback-")); + try { + for (const name of ["agent", "previous-agent", "previous-service", "download"]) { + writeFileSync(join(directory, name), name); + } + const source = readFileSync(new URL(path, import.meta.url), "utf8"); + const result = spawnSync("sh", ["-c", ` + set -eu + ${shellFunctions(source, "log", "cleanup_agent_install")} + stop_agent() { return 0; } + cp() { return 1; } + systemctl() { return 0; } + launchctl() { return 0; } + service() { return 0; } + init_system=systemd + rollback_agent=true + had_agent=true + had_service=true + SERVICE_NAME=fixture + STATE_DIR=$TEST_DIRECTORY + AGENT_FILE=$TEST_DIRECTORY/agent + SERVICE_FILE=$TEST_DIRECTORY/service + PLIST_FILE=$SERVICE_FILE + backup_agent=$TEST_DIRECTORY/previous-agent + backup_service=$TEST_DIRECTORY/previous-service + temporary=$TEST_DIRECTORY/download + if cleanup_agent_install; then exit 0; else exit 1; fi + `], { env: { ...process.env, TEST_DIRECTORY: directory }, encoding: "utf8" }); + expect(result.status).toBe(1); + expect(readFileSync(join(directory, "agent"), "utf8")).toBe("agent"); + expect(existsSync(join(directory, "previous-agent"))).toBe(true); + expect(existsSync(join(directory, "previous-service"))).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } +}); + +describe("Windows update process lifecycle", () => { + const powershell = Bun.which("pwsh"); + for (const kind of ["server", "agent"] as const) { + const source = readFileSync(new URL(kind === "server" ? "../../install.ps1" : "../../agent/install.ps1", import.meta.url), "utf8"); + const stop = kind === "server" ? "Stop-Server" : "Stop-Agent"; + for (const mode of ["stops", "stop-fails", "still-running", "query-fails"] as const) { + test.skipIf(!powershell)(`${kind}: ${mode}`, () => { + const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", ` + $ErrorActionPreference = "Stop" + $TaskName = "fixture" + $ServerFile = $AgentFile = "/fixture/nodeflare.exe" + $script:Stopped = $false + $script:Ticks = 0 + function Get-ScheduledTask { + [pscustomobject]@{ State = $(if ($script:Stopped) { "Ready" } else { "Running" }) } + } + function Stop-ScheduledTask { + if ($env:TEST_MODE -eq "stop-fails") { throw "Stop failed" } + $script:Stopped = $true + } + function Get-CimInstance { + if ($env:TEST_MODE -eq "query-fails") { throw "Process query failed" } + [pscustomobject]@{ ExecutablePath = "/another-program/nodeflare.exe" } + if ($script:Ticks -lt 2 -or $env:TEST_MODE -eq "still-running") { + [pscustomobject]@{ ExecutablePath = $ServerFile } + } + } + function Start-Sleep { $script:Ticks++ } + function Stop-Install([string]$Message) { throw $Message } + function Write-InstallError([string]$Message) { throw $Message } + ${powershellFunction(source, stop)} + try { ${stop}; Write-Output "replaced:$script:Ticks" } catch { Write-Output blocked; exit 1 } + `], { env: { ...process.env, TEST_MODE: mode }, encoding: "utf8", timeout: 10_000 }); + expect(result.status).toBe(mode === "stops" ? 0 : 1); + expect(result.stdout.trim()).toBe(mode === "stops" ? "replaced:2" : "blocked"); + }); + } + } + + test.skipIf(!powershell)("server rollback retains Agent binaries and launchers", () => { + const directory = mkdtempSync(join(tmpdir(), "nodeflare-windows-rollback-")); + try { + for (const path of ["install/share", "backup/share"]) mkdirSync(join(directory, path), { recursive: true }); + for (const [path, value] of Object.entries({ + "install/nodeflare.exe": "broken-server", "install/share/index.html": "broken-frontend", + "install/agent.exe": "newer-agent", "install/run-agent.ps1": "newer-launcher", + "backup/nodeflare.exe": "previous-server", "backup/share/index.html": "previous-frontend", + })) writeFileSync(join(directory, path), value); + const source = readFileSync(new URL("../../install.ps1", import.meta.url), "utf8"); + const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", ` + $ErrorActionPreference = "Stop" + $InstallDir = Join-Path $env:TEST_DIRECTORY "install" + $ServerFile = Join-Path $InstallDir "nodeflare.exe" + $ShareDir = Join-Path $InstallDir "share" + $PreviousInstall = Join-Path $env:TEST_DIRECTORY "backup" + $HadPreviousInstall = $HadPreviousShare = $true + ${powershellFunction(source, "Restore-ServerFiles")} + Restore-ServerFiles + `], { env: { ...process.env, TEST_DIRECTORY: directory }, encoding: "utf8", timeout: 10_000 }); + expect(result.status).toBe(0); + expect(readFileSync(join(directory, "install", "nodeflare.exe"), "utf8")).toBe("previous-server"); + expect(readFileSync(join(directory, "install", "share", "index.html"), "utf8")).toBe("previous-frontend"); + expect(readFileSync(join(directory, "install", "agent.exe"), "utf8")).toBe("newer-agent"); + expect(readFileSync(join(directory, "install", "run-agent.ps1"), "utf8")).toBe("newer-launcher"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/install.ps1 b/install.ps1 index 0a4bf44..7510638 100644 --- a/install.ps1 +++ b/install.ps1 @@ -104,6 +104,30 @@ function Read-Port { } } +function Stop-Server { + $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($null -ne $Task) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction Stop } + for ($Attempt = 0; $Attempt -lt 30; $Attempt++) { + $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + $Running = @(Get-CimInstance Win32_Process -Filter "Name = 'nodeflare.exe'" -ErrorAction Stop | + Where-Object { $_.ExecutablePath -eq $ServerFile }) + if (($null -eq $Task -or $Task.State -ne "Running") -and $Running.Count -eq 0) { return } + Start-Sleep -Milliseconds 500 + } + Stop-Install "旧面板进程未退出,已停止操作,未替换程序" +} + +function Restore-ServerFiles { + if (Test-Path -LiteralPath $ServerFile) { Remove-Item -LiteralPath $ServerFile -Force -ErrorAction Stop } + if (Test-Path -LiteralPath $ShareDir) { Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction Stop } + if ($HadPreviousInstall) { + Copy-Item -LiteralPath (Join-Path $PreviousInstall "nodeflare.exe") -Destination $ServerFile -Force + } + if ($HadPreviousShare) { + Copy-Item -LiteralPath (Join-Path $PreviousInstall "share") -Destination $ShareDir -Recurse -Force + } +} + function Wait-Server { $Task = $null for ($Attempt = 0; $Attempt -lt 10; $Attempt++) { @@ -164,7 +188,7 @@ if ($Mode -eq "Restart") { if ($null -eq $Task -or -not (Test-Path -LiteralPath $ServerFile) -or -not (Test-Path -LiteralPath $ConfigFile)) { Stop-Install "未检测到完整安装,请先选择安装 / 更新" } - Stop-ScheduledTask -TaskName $TaskName + Stop-Server Start-ScheduledTask -TaskName $TaskName Wait-Server Write-Step "服务已重启" @@ -178,7 +202,7 @@ if ($Mode -eq "Uninstall") { } } Write-Step "停止并移除面板服务" - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Stop-Server Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item -LiteralPath $ServerFile -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue @@ -206,7 +230,9 @@ $PackageDir = Join-Path $TemporaryDir "package" $PreviousInstall = Join-Path $TemporaryDir "previous-install" $InstallChanged = $false $HadPreviousInstall = $false +$HadPreviousShare = $false $PreviousTaskXml = $null +$KeepBackup = $false try { New-Item -ItemType Directory -Path $TemporaryDir -Force | Out-Null @@ -279,16 +305,21 @@ try { } else { Write-Step "正在更新至 v$Version" } - $HadPreviousInstall = Test-Path -LiteralPath $InstallDir -PathType Container + $HadPreviousInstall = Test-Path -LiteralPath $ServerFile -PathType Leaf + $HadPreviousShare = Test-Path -LiteralPath $ShareDir -PathType Container + New-Item -ItemType Directory -Path $PreviousInstall -Force | Out-Null if ($HadPreviousInstall) { - Copy-Item -LiteralPath $InstallDir -Destination $PreviousInstall -Recurse -Force + Copy-Item -LiteralPath $ServerFile -Destination (Join-Path $PreviousInstall "nodeflare.exe") -Force + } + if ($HadPreviousShare) { + Copy-Item -LiteralPath $ShareDir -Destination (Join-Path $PreviousInstall "share") -Recurse -Force } $ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($null -ne $ExistingTask) { $PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName } + Stop-Server $InstallChanged = $true - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $InstallDir, $DataDir, $ThemeDir -Force | Out-Null Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue @@ -302,7 +333,7 @@ try { $Action = New-ScheduledTaskAction -Execute $ServerFile -Argument "--config `"$ConfigFile`"" -WorkingDirectory $DataDir $Trigger = New-ScheduledTaskTrigger -AtStartup - $Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650) + $Settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650) $Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null Write-Step "正在启动服务" @@ -313,21 +344,26 @@ try { $InstallChanged = $false Show-InstallResult } catch { + $InstallError = $_ if ($InstallChanged) { if ($HadPreviousInstall) { Write-Warning "安装未完成,正在恢复上一版本" } else { Write-Warning "安装未完成,正在回滚本次更改" } - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue - if ($HadPreviousInstall) { - Copy-Item -LiteralPath $PreviousInstall -Destination $InstallDir -Recurse -Force - } - if ($null -ne $PreviousTaskXml) { - Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null - Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + try { + Stop-Server + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + Restore-ServerFiles + if ($null -ne $PreviousTaskXml) { + Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null + Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop + } + } catch { + $KeepBackup = $true + Write-Warning "自动回滚未完成,备份已保留在 ${PreviousInstall}:$_" } } - throw + throw $InstallError } finally { - Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue + if (-not $KeepBackup) { + Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue + } } diff --git a/install.sh b/install.sh index 00670c7..2fc96a0 100755 --- a/install.sh +++ b/install.sh @@ -146,7 +146,10 @@ restore_tty() { cleanup() { restore_tty if [ "$rollback_ready" = true ]; then - rollback_install + if ! rollback_install; then + printf '错误:自动回滚未完成,备份已保留在 %s\n' "$backup_dir" >&2 + download_dir="" + fi fi [ -z "$config_temp" ] || rm -f "$config_temp" [ -z "$download_dir" ] || rm -rf "$download_dir" @@ -484,11 +487,27 @@ EOF } stop_server() { + [ -f "$(service_definition)" ] || return 0 case "$init_system" in - systemd) systemctl stop nodeflare.service >/dev/null 2>&1 || true ;; - openrc) rc-service nodeflare stop >/dev/null 2>&1 || true ;; - launchd) launchctl bootout system "$launchd_file" >/dev/null 2>&1 || true ;; - freebsd) service nodeflare stop >/dev/null 2>&1 || true ;; + systemd) + systemctl stop nodeflare.service || return 1 + stopped_pid=$(systemctl show -p MainPID --value nodeflare.service) || return 1 + [ "$stopped_pid" = 0 ] + ;; + openrc) + rc-service nodeflare stop || return 1 + ! rc-service nodeflare status >/dev/null 2>&1 + ;; + launchd) + launchctl print system/nodeflare >/dev/null 2>&1 || return 0 + launchctl bootout system "$launchd_file" || return 1 + ! launchctl print system/nodeflare >/dev/null 2>&1 + ;; + freebsd) + service nodeflare onestatus >/dev/null 2>&1 || return 0 + service nodeflare onestop || return 1 + ! service nodeflare onestatus >/dev/null 2>&1 + ;; esac } @@ -504,11 +523,11 @@ service_definition() { snapshot_install() { backup_dir=$download_dir/previous mkdir -p "$backup_dir" - if [ -d "$install_dir" ]; then - cp -Rp "$install_dir" "$backup_dir/install" + if [ -f "$server_binary" ]; then + cp -p "$server_binary" "$backup_dir/nodeflare" previous_install=true fi - if [ "$share_dir" != "$install_dir/share" ] && [ -d "$share_dir" ]; then + if [ -d "$share_dir" ]; then cp -Rp "$share_dir" "$backup_dir/share" previous_share=true fi @@ -517,7 +536,6 @@ snapshot_install() { cp -p "$service_path" "$backup_dir/service" previous_service=true fi - rollback_ready=true } rollback_install() { @@ -527,29 +545,30 @@ rollback_install() { else log "安装未完成,正在回滚本次更改" fi - stop_server - rm -rf "$install_dir" + stop_server || return 1 + rm -f "$server_binary" || return 1 + rm -rf "$share_dir" || return 1 if [ "$previous_install" = true ]; then - cp -Rp "$backup_dir/install" "$install_dir" + mkdir -p "$install_dir" || return 1 + cp -p "$backup_dir/nodeflare" "$server_binary" || return 1 fi - if [ "$share_dir" != "$install_dir/share" ]; then - rm -rf "$share_dir" - if [ "$previous_share" = true ]; then - cp -Rp "$backup_dir/share" "$share_dir" - fi + if [ "$previous_share" = true ]; then + cp -Rp "$backup_dir/share" "$share_dir" || return 1 fi service_path=$(service_definition) - rm -f "$service_path" + rm -f "$service_path" || return 1 if [ "$previous_service" = true ]; then - cp -p "$backup_dir/service" "$service_path" + cp -p "$backup_dir/service" "$service_path" || return 1 fi if [ "$previous_install" = true ] && [ -f "$config_file" ]; then if start_server; then log "已恢复并重新启动上一版本" else printf '警告:上一版本已恢复,但服务未能自动启动\n' >&2 + return 1 fi fi + return 0 } install_service() { @@ -621,7 +640,7 @@ status_server() { restart_server() { [ -x "$server_binary" ] && [ -f "$config_file" ] && [ -f "$(service_definition)" ] \ || fail "未检测到完整安装,请先选择安装 / 更新" - stop_server + stop_server || fail "无法停止已有面板服务,已取消重启" start_server || fail "服务重启失败,请检查日志" log "服务已重启" } @@ -629,7 +648,7 @@ restart_server() { uninstall_server() { purge=$1 log "停止并移除面板服务" - stop_server + stop_server || fail "无法停止已有面板服务,已取消卸载" case "$init_system" in systemd) systemctl disable nodeflare.service >/dev/null 2>&1 || true @@ -738,7 +757,8 @@ else log "正在更新至 v$release_version" fi snapshot_install -stop_server +stop_server || fail "无法停止已有面板服务,未替换程序" +rollback_ready=true install -d -m 0700 "$config_dir" "$theme_dir" install -d -m 0755 "$install_dir" rm -rf "$share_dir"