Offline Detection (9.1):
- AgentOfflineDetectorService: BackgroundService, prüft alle 60s
ob Agents seit >5 min kein Heartbeat hatten → Status=Offline
- IServiceScopeFactory für korrektes Scoped-DI im Singleton
- SignalR-Push AgentStatusChanged bei jeder Offline-Markierung
API Key Auth (9.2):
- ApiKeyMiddleware: prüft X-Api-Key Header gegen Security:ApiKey Config
- Deaktiviert wenn ApiKey leer (Dev-Modus), Swagger/hubs bypassed
- Frontend: getApiKey() aus localStorage, automatisch in allen Requests
- Settings-Modal in Sidebar: API-Key eingeben + maskiert anzeigen
Agent Self-Update (9.3):
- internal/updater/updater.go: CheckForUpdate() + Update()
Download, SHA256-Verify, Windows Batch-Neustart / Linux Shell-Neustart
- AgentReleasesController: GET /api/v1/agent/releases/latest,
GET /api/v1/agent/releases/download/{platform}
- AgentReleaseOptions: LatestVersion, ReleasePath, Checksum in appsettings
- executeCommand() erhält cfg *Config statt agentID string
(für ServerAddress-Ableitung im UpdateAgent-Case)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
156 lines
4.2 KiB
Go
156 lines
4.2 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
)
|
|
|
|
// ReleaseInfo enthält Informationen über die aktuell verfügbare Agent-Version.
|
|
type ReleaseInfo struct {
|
|
Version string `json:"version"`
|
|
DownloadUrl string `json:"downloadUrl"`
|
|
Checksum string `json:"checksum"` // SHA256 hex
|
|
}
|
|
|
|
// CheckForUpdate fragt den Backend-Server nach der aktuellen Version.
|
|
// serverAddress: z.B. "localhost:5000" (REST Port, nicht gRPC)
|
|
func CheckForUpdate(ctx context.Context, serverAddress, currentVersion string) (*ReleaseInfo, error) {
|
|
url := fmt.Sprintf("http://%s/api/v1/agent/releases/latest?os=%s&arch=%s",
|
|
serverAddress, runtime.GOOS, runtime.GOARCH)
|
|
|
|
httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching release info: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status %d from release endpoint", resp.StatusCode)
|
|
}
|
|
|
|
var info ReleaseInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
return nil, fmt.Errorf("decoding release info: %w", err)
|
|
}
|
|
|
|
if info.Version == currentVersion {
|
|
return nil, nil // bereits aktuell
|
|
}
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
// Update lädt die neue Version herunter und startet einen Austausch-Prozess.
|
|
// Nach erfolgreichem Download wird das neue Binary neben das aktuelle gelegt
|
|
// und ein Neustartprozess gestartet.
|
|
func Update(ctx context.Context, info *ReleaseInfo) error {
|
|
binaryPath, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("determining executable path: %w", err)
|
|
}
|
|
|
|
// Download der neuen Version
|
|
dlCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(dlCtx, http.MethodGet, info.DownloadUrl, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("creating download request: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("downloading new binary: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status %d during download", resp.StatusCode)
|
|
}
|
|
|
|
fileBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("reading download body: %w", err)
|
|
}
|
|
|
|
// SHA256-Prüfung (überspringen wenn Checksum leer = Dev-Modus)
|
|
if info.Checksum != "" {
|
|
sum := sha256.Sum256(fileBytes)
|
|
got := hex.EncodeToString(sum[:])
|
|
if got != info.Checksum {
|
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", info.Checksum, got)
|
|
}
|
|
}
|
|
|
|
newBinaryPath := binaryPath + ".new"
|
|
if err := os.WriteFile(newBinaryPath, fileBytes, 0755); err != nil {
|
|
return fmt.Errorf("writing new binary: %w", err)
|
|
}
|
|
|
|
// Platform-spezifischer Neustart
|
|
if runtime.GOOS == "windows" {
|
|
return restartWindows(binaryPath, newBinaryPath)
|
|
}
|
|
return restartUnix(binaryPath, newBinaryPath)
|
|
}
|
|
|
|
func restartWindows(binaryPath, newBinaryPath string) error {
|
|
batchContent := fmt.Sprintf(`@echo off
|
|
timeout /t 2 /nobreak > nul
|
|
move /y "%s" "%s"
|
|
start "" "%s"
|
|
del "%%~f0"
|
|
`, newBinaryPath, binaryPath, binaryPath)
|
|
|
|
batchPath := filepath.Join(os.TempDir(), "nexus-agent-update.bat")
|
|
if err := os.WriteFile(batchPath, []byte(batchContent), 0644); err != nil {
|
|
return fmt.Errorf("writing update batch script: %w", err)
|
|
}
|
|
|
|
if err := exec.Command("cmd", "/c", "start", "", batchPath).Start(); err != nil {
|
|
return fmt.Errorf("starting update batch script: %w", err)
|
|
}
|
|
|
|
os.Exit(0)
|
|
return nil
|
|
}
|
|
|
|
func restartUnix(binaryPath, newBinaryPath string) error {
|
|
scriptContent := fmt.Sprintf(`#!/bin/sh
|
|
sleep 2
|
|
mv -f "%s" "%s"
|
|
"%s" &
|
|
rm -f "$0"
|
|
`, newBinaryPath, binaryPath, binaryPath)
|
|
|
|
scriptPath := filepath.Join(os.TempDir(), "nexus-agent-update.sh")
|
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
|
return fmt.Errorf("writing update shell script: %w", err)
|
|
}
|
|
|
|
if err := exec.Command("sh", scriptPath).Start(); err != nil {
|
|
return fmt.Errorf("starting update shell script: %w", err)
|
|
}
|
|
|
|
os.Exit(0)
|
|
return nil
|
|
}
|