feat: Windows Service Wrapper für NexusRMM Agent

- 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 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-20 10:24:00 +01:00
parent c401ea8f29
commit d7b618f02d
4 changed files with 475 additions and 74 deletions

View File

@@ -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), &params)
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)

View File

@@ -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")
}

View File

@@ -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
}