diff --git a/.gitea/workflows/agent-release.yml b/.gitea/workflows/agent-release.yml index f539546..5ca0f45 100644 --- a/.gitea/workflows/agent-release.yml +++ b/.gitea/workflows/agent-release.yml @@ -68,12 +68,23 @@ jobs: cache: true cache-dependency-path: agent/go.sum + - name: Extract version + id: version + shell: pwsh + run: | + $v = "${{ github.ref_name }}" -replace '^agent/v', '' + "VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT + # MSI ProductVersion must be numeric x.x.x.x + "MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT + - name: Build agent exe working-directory: agent shell: pwsh + env: + VERSION: ${{ steps.version.outputs.VERSION }} run: | $env:GOOS = "windows"; $env:GOARCH = "amd64" - go build -o ../installer/vantage-agent-windows-amd64.exe ./cmd + go build -ldflags="-s -w -X main.Version=$env:VERSION" -o ../installer/vantage-agent-windows-amd64.exe ./cmd - name: Cache nssm id: cache-nssm @@ -100,7 +111,7 @@ jobs: shell: pwsh run: | $env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools" - wix build vantage-agent.wxs -o vantage-agent.msi + wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi (Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt - name: Attach MSI to release diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go index 07ab92b..b6a8cc8 100644 --- a/agent/internal/sync/sync.go +++ b/agent/internal/sync/sync.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "runtime" "strings" "time" @@ -265,6 +266,11 @@ func handleDeleteKey(cmd *pb.ServerCommand) { } func handleUpdateAgent(cmd *pb.ServerCommand) { + if runtime.GOOS == "windows" { + handleUpdateAgentWindows(cmd) + return + } + u := cmd.UpdateAgent arch := runtime.GOARCH // "amd64" or "arm64" tag := "agent%2Fv" + u.Version @@ -305,6 +311,47 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { exec.Command("systemctl", "restart", "vantage-agent").Run() } +// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to +// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so +// that when the upgrade stops the VantageAgent service, nssm's process-tree +// kill of this agent does not also kill the installer mid-flight. Config +// (server_id, agent_token) is preserved by setup.ps1 on upgrade. +func handleUpdateAgentWindows(cmd *pb.ServerCommand) { + u := cmd.UpdateAgent + tag := "agent%2Fv" + u.Version + msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag) + checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag) + + log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) + + msiPath := filepath.Join(os.TempDir(), "vantage-agent-update.msi") + if err := downloadFile(msiURL, msiPath); err != nil { + log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err) + return + } + + checksumData, err := httpGetBytes(checksumURL) + if err != nil { + log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err) + return + } + if err := verifyChecksum(msiPath, "vantage-agent.msi", checksumData); err != nil { + log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err) + os.Remove(msiPath) + return + } + + logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log") + log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId) + // "start" detaches msiexec from this process tree so the service stop + // during the upgrade does not terminate the installer. + up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath) + if err := up.Start(); err != nil { + log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err) + return + } +} + func downloadFile(url, dest string) error { resp, err := http.Get(url) //nolint:gosec if err != nil { diff --git a/installer/setup.ps1 b/installer/setup.ps1 index caf7fc4..d32cb6f 100644 --- a/installer/setup.ps1 +++ b/installer/setup.ps1 @@ -66,8 +66,17 @@ try { $cfgDir = Join-Path $env:ProgramData "vantage" New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null + $cfgPath = Join-Path $cfgDir "config.yaml" - $cfg = @" + # Preserve existing config on upgrade. A MajorUpgrade re-runs this script with + # no SERVERID/TOKEN, so blindly rewriting would wipe the agent_token the agent + # persisted after Register(). Only (re)write when a ServerId is supplied + # (fresh install / explicit re-register). + if ((Test-Path $cfgPath) -and (-not $ServerId)) { + Write-Log "config.yaml exists and no ServerId supplied - preserving existing config (upgrade)" + } + else { + $cfg = @" server_url: "$ServerUrl" server_id: "$ServerId" pre_reg_token: "$Token" @@ -75,12 +84,12 @@ agent_token: "" poll_interval: 30s tls: true "@ - $cfgPath = Join-Path $cfgDir "config.yaml" - Set-Content -Path $cfgPath -Value $cfg -Encoding utf8 - Write-Log "wrote $cfgPath" + Set-Content -Path $cfgPath -Value $cfg -Encoding utf8 + Write-Log "wrote $cfgPath" - # Lock down ACL: SYSTEM + Administrators only - Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F") + # Lock down ACL: SYSTEM + Administrators only + Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F") + } if (-not $InstallDir) { $InstallDir = $PSScriptRoot } $nssm = Join-Path $InstallDir "nssm.exe" @@ -89,9 +98,31 @@ tls: true if (-not (Test-Path $nssm)) { throw "nssm.exe not found at $nssm" } if (-not (Test-Path $exe)) { throw "vantage-agent.exe not found at $exe" } - Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe) + # Install only if the service isn't already registered (an upgrade may leave + # it in place). "nssm install" on an existing service errors otherwise. + $exists = Get-Service -Name "VantageAgent" -ErrorAction SilentlyContinue + if (-not $exists) { + Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe) + } else { + Write-Log "VantageAgent service already exists - updating binary path" + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Application", $exe) + } Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START") - Invoke-Native -File $nssm -Arguments @("start", "VantageAgent") + + # Redirect service stdout/stderr to log files (nssm discards them otherwise) + # with online rotation at ~1MB. + $outLog = Join-Path $logDir "agent-stdout.log" + $errLog = Join-Path $logDir "agent-stderr.log" + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdout", $outLog) + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderr", $errLog) + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdoutCreationDisposition", "4") + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderrCreationDisposition", "4") + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateFiles", "1") + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1") + Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576") + + # restart (not just start) so an upgrade picks up the new binary + Invoke-NativeSoft -File $nssm -Arguments @("restart", "VantageAgent") Write-Log "=== setup ok ===" exit 0 diff --git a/installer/vantage-agent.wxs b/installer/vantage-agent.wxs index e4a53a9..2f81fbe 100644 --- a/installer/vantage-agent.wxs +++ b/installer/vantage-agent.wxs @@ -1,7 +1,8 @@ + diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 3600e01..3dbc507 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -23,6 +23,7 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/install", handleInstallScript) r.GET("/install.ps1", handleInstallScriptWindows) r.GET("/update", handleUpdateScript) + r.GET("/update.ps1", handleUpdateScriptWindows) // ESO read endpoint — bearer-token auth, not session auth, so Kubernetes // External Secrets Operator can call it. Lives under /api (so the reverse diff --git a/server/internal/api/install_ps1.go b/server/internal/api/install_ps1.go index 8890787..485e551 100644 --- a/server/internal/api/install_ps1.go +++ b/server/internal/api/install_ps1.go @@ -54,3 +54,40 @@ func handleInstallScriptWindows(c *gin.Context) { c.Header("Content-Type", "text/plain; charset=utf-8") c.String(http.StatusOK, script) } + +// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an +// already-installed Windows agent. No server_id/token needed: the MSI is a +// MajorUpgrade and setup.ps1 preserves the existing config on upgrade. +func handleUpdateScriptWindows(c *gin.Context) { + giteaHost := os.Getenv("GITEA_HOST") + if giteaHost == "" { + giteaHost = "gitea.example.com" + } + + script := fmt.Sprintf( + "#Requires -RunAsAdministrator\n"+ + "$ErrorActionPreference = \"Stop\"\n"+ + "\n"+ + "$GiteaHost = \"%s\"\n"+ + "\n"+ + "$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+ + "$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+ + "if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+ + "$enc = $tag -replace '/','%%2F'\n"+ + "$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+ + "\n"+ + "$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+ + "Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+ + "Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+ + "\n"+ + "$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+ + "$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+ + "if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+ + "\n"+ + "Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn /norestart\"\n"+ + "Write-Host \"Vantage agent updated to $tag.\"\n", + giteaHost) + + c.Header("Content-Type", "text/plain; charset=utf-8") + c.String(http.StatusOK, script) +} diff --git a/web/app/servers/[id]/page.tsx b/web/app/servers/[id]/page.tsx index e5b5acf..8754391 100644 --- a/web/app/servers/[id]/page.tsx +++ b/web/app/servers/[id]/page.tsx @@ -416,10 +416,10 @@ export default function ServerDetailPage() { {updateSuccess ? "Update Sent!" : "Update Agent"}
- $ {api.getUpdateCommand()} + {server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"} {api.getUpdateCommand(server.os_info)}