From d7b618f02d840eafaa6510a0bc3d41d92690aa09 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Fri, 20 Mar 2026 10:24:00 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Windows=20Service=20Wrapper=20f=C3=BCr?= =?UTF-8?q?=20NexusRMM=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - winsvc package mit Build-Tag-basierter plattformspezifischer Implementierung: - service_windows.go: svc.Handler + Install/Uninstall/Start/Stop via windows/svc/mgr - service_stub.go: Stub-Implementierungen für nicht-Windows Builds - main.go refaktoriert: - os.Executable() für absoluten Config-Pfad (Service-Modus: CWD = C:\Windows\System32) - Kommandozeilen-Args: install / uninstall / start / stop - winsvc.IsWindowsService() Erkennung → Service-Modus oder Konsolen-Modus - Agent-Loop als makeRunFn() extrahiert (wiederverwendbar für beide Modi) - install-service.ps1: Convenience-Skript zum Bauen, Installieren und Starten Co-Authored-By: Claude Sonnet 4.6 --- Agent/cmd/agent/main.go | 220 +++++++++++++++-------- Agent/internal/winsvc/service_stub.go | 33 ++++ Agent/internal/winsvc/service_windows.go | 169 +++++++++++++++++ install-service.ps1 | 127 +++++++++++++ 4 files changed, 475 insertions(+), 74 deletions(-) create mode 100644 Agent/internal/winsvc/service_stub.go create mode 100644 Agent/internal/winsvc/service_windows.go create mode 100644 install-service.ps1 diff --git a/Agent/cmd/agent/main.go b/Agent/cmd/agent/main.go index 416c4cc..3cf6709 100644 --- a/Agent/cmd/agent/main.go +++ b/Agent/cmd/agent/main.go @@ -7,6 +7,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "runtime" "strings" "syscall" @@ -20,91 +21,164 @@ import ( "nexusrmm.local/agent/internal/meshagent" "nexusrmm.local/agent/internal/scanner" "nexusrmm.local/agent/internal/updater" + "nexusrmm.local/agent/internal/winsvc" pb "nexusrmm.local/agent/pkg/proto" ) var version = "dev" func main() { + // Absoluten Pfad zur EXE ermitteln — kritisch im Service-Modus, + // da der Working Directory dann C:\Windows\System32 ist. + exePath, err := os.Executable() + if err != nil { + log.Fatalf("Konnte EXE-Pfad nicht ermitteln: %v", err) + } + exeDir := filepath.Dir(exePath) + configPath := filepath.Join(exeDir, "nexus-agent.yaml") + + // Kommandozeilen-Argument verarbeiten (install / uninstall / start / stop) + if len(os.Args) > 1 { + switch os.Args[1] { + case "install": + if err := winsvc.Install(exePath); err != nil { + fmt.Fprintf(os.Stderr, "Fehler: %v\n", err) + os.Exit(1) + } + fmt.Println("Service erfolgreich installiert.") + return + case "uninstall": + if err := winsvc.Uninstall(); err != nil { + fmt.Fprintf(os.Stderr, "Fehler: %v\n", err) + os.Exit(1) + } + fmt.Println("Service erfolgreich deinstalliert.") + return + case "start": + if err := winsvc.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Fehler: %v\n", err) + os.Exit(1) + } + fmt.Println("Service gestartet.") + return + case "stop": + if err := winsvc.Stop(); err != nil { + fmt.Fprintf(os.Stderr, "Fehler: %v\n", err) + os.Exit(1) + } + fmt.Println("Service gestoppt.") + return + } + } + + // Prüfen ob wir als Windows Service laufen + isSvc, err := winsvc.IsWindowsService() + if err != nil { + log.Fatalf("Service-Erkennung fehlgeschlagen: %v", err) + } + + if isSvc { + // Als Windows Service starten — blockiert bis Service gestoppt wird + if err := winsvc.Run(makeRunFn(configPath)); err != nil { + log.Fatalf("Service-Fehler: %v", err) + } + return + } + + // Konsolen-Modus: direkt ausführen mit Ctrl+C-Signal log.Printf("NexusRMM Agent %s starting on %s/%s", version, runtime.GOOS, runtime.GOARCH) - cfg, err := config.Load("nexus-agent.yaml") - if err != nil { - log.Fatalf("Config load error: %v", err) - } - - client, err := connection.ConnectWithRetry(cfg.ServerAddress, 10) - if err != nil { - log.Fatalf("Failed to connect: %v", err) - } - defer client.Close() - - if cfg.AgentID == "" { - hostname, _ := os.Hostname() - metrics, _ := collector.Collect() - // Beste Netzwerkschnittstelle für Enrollment wählen (bevorzuge IPv4) - mac, ip := "", "" - for _, n := range metrics.Networks { - if n.MAC == "" || n.IPAddress == "" { - continue - } - if mac == "" { - mac, ip = n.MAC, n.IPAddress - } - // Sobald IPv4 gefunden: nehmen und fertig - if !strings.Contains(n.IPAddress, ":") { - mac, ip = n.MAC, n.IPAddress - break - } - } - - resp, err := client.Client.Enroll(context.Background(), &pb.EnrollRequest{ - Hostname: hostname, - OsType: runtime.GOOS, - OsVersion: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), - MacAddress: mac, - IpAddress: ip, - AgentVersion: version, - }) - if err != nil { - log.Fatalf("Enrollment failed: %v", err) - } - cfg.AgentID = resp.AgentId - cfg.HeartbeatInterval = int(resp.HeartbeatInterval) - if err := cfg.Save("nexus-agent.yaml"); err != nil { - log.Printf("Warning: could not save config: %v", err) - } - log.Printf("Enrolled with ID: %s", cfg.AgentID) - } - - // MeshAgent installieren falls konfiguriert - if cfg.MeshEnabled && cfg.MeshCentralUrl != "" { - log.Printf("Installiere MeshAgent von %s...", cfg.MeshCentralUrl) - if err := meshagent.Install(context.Background(), cfg.MeshCentralUrl); err != nil { - log.Printf("MeshAgent-Installation fehlgeschlagen (nicht kritisch): %v", err) - } - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + stop := make(chan struct{}) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("Shutting down...") + close(stop) + }() - ticker := time.NewTicker(time.Duration(cfg.HeartbeatInterval) * time.Second) - defer ticker.Stop() + makeRunFn(configPath)(stop) +} - log.Printf("Agent running. Heartbeat every %ds", cfg.HeartbeatInterval) - doHeartbeat(ctx, client, cfg) +// makeRunFn gibt die Haupt-Agent-Loop als Funktion zurück. +// stop wird geschlossen wenn der Agent beendet werden soll (Service-Stop oder Ctrl+C). +func makeRunFn(configPath string) func(stop <-chan struct{}) { + return func(stop <-chan struct{}) { + log.Printf("NexusRMM Agent %s starting on %s/%s", version, runtime.GOOS, runtime.GOARCH) - for { - select { - case <-ticker.C: - doHeartbeat(ctx, client, cfg) - case <-sigCh: - log.Println("Shutting down...") - return - case <-ctx.Done(): - return + cfg, err := config.Load(configPath) + if err != nil { + log.Fatalf("Config load error: %v", err) + } + + client, err := connection.ConnectWithRetry(cfg.ServerAddress, 10) + if err != nil { + log.Fatalf("Failed to connect: %v", err) + } + defer client.Close() + + if cfg.AgentID == "" { + hostname, _ := os.Hostname() + metrics, _ := collector.Collect() + mac, ip := "", "" + for _, n := range metrics.Networks { + if n.MAC == "" || n.IPAddress == "" { + continue + } + if mac == "" { + mac, ip = n.MAC, n.IPAddress + } + if !strings.Contains(n.IPAddress, ":") { + mac, ip = n.MAC, n.IPAddress + break + } + } + + resp, err := client.Client.Enroll(context.Background(), &pb.EnrollRequest{ + Hostname: hostname, + OsType: runtime.GOOS, + OsVersion: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + MacAddress: mac, + IpAddress: ip, + AgentVersion: version, + }) + if err != nil { + log.Fatalf("Enrollment failed: %v", err) + } + cfg.AgentID = resp.AgentId + cfg.HeartbeatInterval = int(resp.HeartbeatInterval) + if err := cfg.Save(configPath); err != nil { + log.Printf("Warning: could not save config: %v", err) + } + log.Printf("Enrolled with ID: %s", cfg.AgentID) + } + + if cfg.MeshEnabled && cfg.MeshCentralUrl != "" { + log.Printf("Installiere MeshAgent von %s...", cfg.MeshCentralUrl) + if err := meshagent.Install(context.Background(), cfg.MeshCentralUrl); err != nil { + log.Printf("MeshAgent-Installation fehlgeschlagen (nicht kritisch): %v", err) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ticker := time.NewTicker(time.Duration(cfg.HeartbeatInterval) * time.Second) + defer ticker.Stop() + + log.Printf("Agent running. Heartbeat every %ds", cfg.HeartbeatInterval) + doHeartbeat(ctx, client, cfg) + + for { + select { + case <-ticker.C: + doHeartbeat(ctx, client, cfg) + case <-stop: + log.Println("Agent wird beendet...") + return + case <-ctx.Done(): + return + } } } } @@ -173,13 +247,11 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, cfg *con } } case pb.CommandType_COMMAND_TYPE_UPDATE_AGENT: - // Payload optional: {"serverAddress": "localhost:5000"} var params struct { ServerAddress string `json:"serverAddress"` } _ = json.Unmarshal([]byte(cmd.Payload), ¶ms) if params.ServerAddress == "" { - // Aus gRPC-Adresse REST-Adresse ableiten (Port 5000 statt 5001) params.ServerAddress = strings.Replace(cfg.ServerAddress, "5001", "5000", 1) } info, err := updater.CheckForUpdate(ctx, params.ServerAddress, version) diff --git a/Agent/internal/winsvc/service_stub.go b/Agent/internal/winsvc/service_stub.go new file mode 100644 index 0000000..7a4e069 --- /dev/null +++ b/Agent/internal/winsvc/service_stub.go @@ -0,0 +1,33 @@ +//go:build !windows + +package winsvc + +import "errors" + +const ( + ServiceName = "NexusRMMAgent" + ServiceDisplayName = "NexusRMM Agent" + ServiceDescription = "NexusRMM Remote Monitoring and Management Agent" +) + +func IsWindowsService() (bool, error) { return false, nil } + +func Run(_ func(stop <-chan struct{})) error { + return errors.New("Windows Service wird nur unter Windows unterstützt") +} + +func Install(_ string) error { + return errors.New("Windows Service wird nur unter Windows unterstützt") +} + +func Uninstall() error { + return errors.New("Windows Service wird nur unter Windows unterstützt") +} + +func Start() error { + return errors.New("Windows Service wird nur unter Windows unterstützt") +} + +func Stop() error { + return errors.New("Windows Service wird nur unter Windows unterstützt") +} diff --git a/Agent/internal/winsvc/service_windows.go b/Agent/internal/winsvc/service_windows.go new file mode 100644 index 0000000..629ccc2 --- /dev/null +++ b/Agent/internal/winsvc/service_windows.go @@ -0,0 +1,169 @@ +//go:build windows + +package winsvc + +import ( + "fmt" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + ServiceName = "NexusRMMAgent" + ServiceDisplayName = "NexusRMM Agent" + ServiceDescription = "NexusRMM Remote Monitoring and Management Agent" +) + +// IsWindowsService gibt true zurück wenn der Prozess als Windows Service läuft. +func IsWindowsService() (bool, error) { + return svc.IsWindowsService() +} + +type agentSvc struct { + runFn func(stop <-chan struct{}) +} + +func (s *agentSvc) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + + changes <- svc.Status{State: svc.StartPending} + + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + s.runFn(stop) + }() + + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + +loop: + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending} + close(stop) + break loop + } + case <-done: + break loop + } + } + + // Auf Agent-Shutdown warten (max 10s) + select { + case <-done: + case <-time.After(10 * time.Second): + } + return false, 0 +} + +// Run führt den Agent als Windows Service aus. +// runFn wird in einer Goroutine gestartet; stop wird geschlossen wenn der Service gestoppt wird. +func Run(runFn func(stop <-chan struct{})) error { + return svc.Run(ServiceName, &agentSvc{runFn: runFn}) +} + +// Install registriert den Service im Windows Service Manager. +// exePath muss der absolute Pfad zur nexus-agent.exe sein. +func Install(exePath string) error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err) + } + defer m.Disconnect() + + // Prüfen ob Service bereits existiert + existing, err := m.OpenService(ServiceName) + if err == nil { + existing.Close() + return fmt.Errorf("Service '%s' ist bereits installiert", ServiceName) + } + + s, err := m.CreateService(ServiceName, exePath, mgr.Config{ + DisplayName: ServiceDisplayName, + Description: ServiceDescription, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ServiceRestart, + }) + if err != nil { + return fmt.Errorf("Service erstellen fehlgeschlagen: %w", err) + } + defer s.Close() + + return nil +} + +// Uninstall entfernt den Service (stoppt ihn vorher falls nötig). +func Uninstall() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err != nil { + return fmt.Errorf("Service '%s' nicht gefunden: %w", ServiceName, err) + } + defer s.Close() + + // Service stoppen falls er läuft + status, err := s.Query() + if err == nil && status.State != svc.Stopped { + if _, err := s.Control(svc.Stop); err == nil { + // Kurz warten bis er gestoppt ist + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + st, e := s.Query() + if e != nil || st.State == svc.Stopped { + break + } + } + } + } + + if err := s.Delete(); err != nil { + return fmt.Errorf("Service löschen fehlgeschlagen: %w", err) + } + return nil +} + +// Start startet den installierten Service. +func Start() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err != nil { + return fmt.Errorf("Service '%s' nicht gefunden: %w", ServiceName, err) + } + defer s.Close() + + return s.Start() +} + +// Stop stoppt den laufenden Service. +func Stop() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err != nil { + return fmt.Errorf("Service '%s' nicht gefunden: %w", ServiceName, err) + } + defer s.Close() + + _, err = s.Control(svc.Stop) + return err +} diff --git a/install-service.ps1 b/install-service.ps1 new file mode 100644 index 0000000..9deb33b --- /dev/null +++ b/install-service.ps1 @@ -0,0 +1,127 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Installiert den NexusRMM Agent als Windows Service +.DESCRIPTION + Baut den Agent, kopiert ihn ins Zielverzeichnis und registriert ihn als Windows Service. + Muss als Administrator ausgefuehrt werden. +.PARAMETER ServerAddress + gRPC-Adresse des NexusRMM Servers (Standard: localhost:5001) +.PARAMETER InstallDir + Installationsverzeichnis (Standard: C:\Program Files\NexusRMM) +#> +param( + [string]$ServerAddress = "localhost:5001", + [string]$InstallDir = "C:\Program Files\NexusRMM" +) + +$ErrorActionPreference = "Continue" +$Root = $PSScriptRoot + +function Write-Step { param($msg) Write-Host "" ; Write-Host "==> $msg" -ForegroundColor Cyan } +function Write-OK { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green } +function Write-Fail { param($msg) Write-Host " [FEHLER] $msg" -ForegroundColor Red } + +# --------------------------------------------------------------------------- +# 1. Agent bauen +# --------------------------------------------------------------------------- +Write-Step "Baue NexusRMM Agent..." + +Set-Location "$Root\Agent" +$env:GOOS = "windows" +$env:GOARCH = "amd64" +go build -ldflags "-s -w -X main.version=1.0.0" -o nexus-agent.exe ./cmd/agent +if ($LASTEXITCODE -ne 0) { + Write-Fail "go build fehlgeschlagen." + exit 1 +} +Write-OK "nexus-agent.exe erstellt." + +# --------------------------------------------------------------------------- +# 2. Installationsverzeichnis anlegen +# --------------------------------------------------------------------------- +Write-Step "Lege Installationsverzeichnis an: $InstallDir" + +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} +Write-OK "Verzeichnis bereit." + +# --------------------------------------------------------------------------- +# 3. Dateien kopieren +# --------------------------------------------------------------------------- +Write-Step "Kopiere Dateien..." + +Copy-Item "$Root\Agent\nexus-agent.exe" "$InstallDir\nexus-agent.exe" -Force + +# Konfigurationsdatei anlegen falls nicht vorhanden +$configPath = "$InstallDir\nexus-agent.yaml" +if (-not (Test-Path $configPath)) { + @" +serverAddress: $ServerAddress +agentId: "" +heartbeatInterval: 30 +meshEnabled: false +meshCentralUrl: "" +"@ | Out-File -FilePath $configPath -Encoding utf8 + Write-OK "nexus-agent.yaml erstellt (serverAddress: $ServerAddress)." +} else { + Write-OK "nexus-agent.yaml bereits vorhanden, wird nicht ueberschrieben." +} + +# --------------------------------------------------------------------------- +# 4. Bestehenden Service stoppen/deinstallieren falls vorhanden +# --------------------------------------------------------------------------- +$exePath = "$InstallDir\nexus-agent.exe" + +$svcCheck = Get-Service -Name "NexusRMMAgent" -ErrorAction SilentlyContinue +if ($svcCheck) { + Write-Step "Entferne vorhandenen Service..." + & $exePath stop 2>$null + Start-Sleep -Seconds 2 + & $exePath uninstall + if ($LASTEXITCODE -ne 0) { + Write-Fail "Service-Deinstallation fehlgeschlagen." + exit 1 + } + Write-OK "Alter Service entfernt." +} + +# --------------------------------------------------------------------------- +# 5. Service installieren und starten +# --------------------------------------------------------------------------- +Write-Step "Installiere Service..." + +& $exePath install +if ($LASTEXITCODE -ne 0) { + Write-Fail "Service-Installation fehlgeschlagen." + exit 1 +} +Write-OK "Service installiert." + +Write-Step "Starte Service..." +& $exePath start +if ($LASTEXITCODE -ne 0) { + Write-Fail "Service konnte nicht gestartet werden." + exit 1 +} +Write-OK "Service gestartet." + +# --------------------------------------------------------------------------- +# Zusammenfassung +# --------------------------------------------------------------------------- +Write-Host "" +Write-Host "+--------------------------------------------------+" -ForegroundColor Green +Write-Host "| NexusRMM Agent als Service installiert! |" -ForegroundColor Green +Write-Host "+--------------------------------------------------+" -ForegroundColor Green +Write-Host "| Installiert in: $InstallDir" -ForegroundColor Green +Write-Host "| Server: $ServerAddress" -ForegroundColor Green +Write-Host "| Service-Name: NexusRMMAgent" -ForegroundColor Green +Write-Host "+--------------------------------------------------+" -ForegroundColor Green +Write-Host "" +Write-Host "Verwalten:" -ForegroundColor Yellow +Write-Host " Start: & '$exePath' start" -ForegroundColor Yellow +Write-Host " Stop: & '$exePath' stop" -ForegroundColor Yellow +Write-Host " Entfernen: & '$exePath' uninstall" -ForegroundColor Yellow +Write-Host " Oder via: services.msc -> NexusRMMAgent" -ForegroundColor Yellow +Write-Host ""