diff --git a/Makefile b/Makefile index 3395db5..ac9fff4 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ -.PHONY: all backend agent agent-linux updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend +.PHONY: all backend agent agent-linux agent-windows updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend VERSION ?= 0.0.0 BACKEND_BIN = build/rmm-backend AGENT_BIN = build/rmm-agent AGENT_LINUX_BIN = build/rmm-agent-linux +AGENT_WINDOWS_BIN = build/rmm-agent-windows.exe UPDATER_FREEBSD_BIN = build/rmm-updater-freebsd UPDATER_LINUX_BIN = build/rmm-updater-linux @@ -28,6 +29,11 @@ agent-linux: cd agent-linux && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_LINUX_BIN) . @echo "==> $(AGENT_LINUX_BIN) erstellt" +agent-windows: + @echo "==> Building Agent Windows (windows/amd64)..." + cd agent-windows && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_WINDOWS_BIN) . + @echo "==> $(AGENT_WINDOWS_BIN) erstellt" + updater-freebsd: @echo "==> Building Updater (freebsd/amd64)..." cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_FREEBSD_BIN) . diff --git a/agent-windows/client/client.go b/agent-windows/client/client.go new file mode 100644 index 0000000..95f16c2 --- /dev/null +++ b/agent-windows/client/client.go @@ -0,0 +1,102 @@ +package client + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +func New(baseURL, apiKey string, insecure bool) *Client { + return &Client{ + baseURL: baseURL, + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + }, + }, + } +} + +type RegisterRequest struct { + AgentID string `json:"agent_id,omitempty"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + AgentVersion string `json:"agent_version,omitempty"` + Platform string `json:"platform"` +} + +type RegisterResponse struct { + ID string `json:"id"` + Message string `json:"message"` +} + +func (c *Client) Register(req RegisterRequest) (string, error) { + body, _ := json.Marshal(req) + resp, err := c.doRequest("POST", "/api/v1/agent/register", body) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Registrierung fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(data)) + } + var r RegisterResponse + if err := json.Unmarshal(data, &r); err != nil { + return "", fmt.Errorf("JSON Parse: %v", err) + } + return r.ID, nil +} + +type HeartbeatRequest struct { + AgentID string `json:"agent_id"` + AgentVersion string `json:"agent_version,omitempty"` + Platform string `json:"platform"` + SystemData json.RawMessage `json:"system_data,omitempty"` +} + +type HeartbeatResponse struct { + Message string `json:"message"` + UpdateAvailable bool `json:"update_available"` + UpdateVersion string `json:"update_version,omitempty"` + UpdateHash string `json:"update_hash,omitempty"` +} + +func (c *Client) Heartbeat(req HeartbeatRequest) (*HeartbeatResponse, error) { + body, _ := json.Marshal(req) + resp, err := c.doRequest("POST", "/api/v1/agent/heartbeat", body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Heartbeat fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(data)) + } + var r HeartbeatResponse + json.Unmarshal(data, &r) + return &r, nil +} + +func (c *Client) doRequest(method, path string, body []byte) (*http.Response, error) { + req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", c.apiKey) + return c.httpClient.Do(req) +} diff --git a/agent-windows/collector/system.go b/agent-windows/collector/system.go new file mode 100644 index 0000000..d9f7555 --- /dev/null +++ b/agent-windows/collector/system.go @@ -0,0 +1,354 @@ +package collector + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strconv" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// SystemData ist das Haupt-Daten-Struct das an das Backend gesendet wird +type SystemData struct { + Windows *WindowsData `json:"windows,omitempty"` +} + +type WindowsData struct { + Hostname string `json:"hostname"` + OSVersion string `json:"os_version"` + OSBuild string `json:"os_build"` + UptimeSecs int64 `json:"uptime_seconds"` + CPU CPUInfo `json:"cpu"` + Memory MemoryInfo `json:"memory"` + Disks []DiskInfo `json:"disks"` + Services []SvcInfo `json:"services,omitempty"` + Domain string `json:"domain,omitempty"` + LastUpdate time.Time `json:"last_update"` +} + +type CPUInfo struct { + Model string `json:"model"` + Cores int `json:"cores"` + LogicalCores int `json:"logical_cores"` + LoadPercent float64 `json:"load_percent"` +} + +type MemoryInfo struct { + TotalBytes uint64 `json:"total_bytes"` + AvailableBytes uint64 `json:"available_bytes"` + UsedPercent float64 `json:"used_percent"` +} + +type DiskInfo struct { + Drive string `json:"drive"` + Label string `json:"label,omitempty"` + TotalBytes uint64 `json:"total_bytes"` + FreeBytes uint64 `json:"free_bytes"` + UsedPercent float64 `json:"used_percent"` + Filesystem string `json:"filesystem,omitempty"` +} + +type SvcInfo struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Status string `json:"status"` + StartType string `json:"start_type"` +} + +// MEMORYSTATUSEX fuer GlobalMemoryStatusEx +type memoryStatusEx struct { + dwLength uint32 + dwMemoryLoad uint32 + ullTotalPhys uint64 + ullAvailPhys uint64 + ullTotalPageFile uint64 + ullAvailPageFile uint64 + ullTotalVirtual uint64 + ullAvailVirtual uint64 + ullAvailExtendedVirtual uint64 +} + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") + procGetTickCount64 = kernel32.NewProc("GetTickCount64") + procGetSystemTimes = kernel32.NewProc("GetSystemTimes") +) + +// Collect sammelt alle Systemdaten +func Collect() (*SystemData, error) { + wd := &WindowsData{ + LastUpdate: time.Now(), + } + + // Hostname + if h, err := os.Hostname(); err == nil { + wd.Hostname = h + } + + // OS-Version + wd.OSVersion, wd.OSBuild = getOSVersion() + + // Uptime + wd.UptimeSecs = getUptime() + + // CPU + wd.CPU = getCPUInfo() + + // Memory + wd.Memory = getMemoryInfo() + + // Disks + wd.Disks = getDiskInfo() + + // Domain + wd.Domain = getDomain() + + return &SystemData{Windows: wd}, nil +} + +func getOSVersion() (version, build string) { + // Registry: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return "Windows (unbekannt)", "" + } + defer k.Close() + + productName, _, _ := k.GetStringValue("ProductName") + displayVersion, _, _ := k.GetStringValue("DisplayVersion") + currentBuild, _, _ := k.GetStringValue("CurrentBuild") + ubr, _, _ := k.GetIntegerValue("UBR") + + if displayVersion != "" { + version = fmt.Sprintf("%s %s", productName, displayVersion) + } else { + version = productName + } + if ubr > 0 { + build = fmt.Sprintf("%s.%d", currentBuild, ubr) + } else { + build = currentBuild + } + return +} + +func getUptime() int64 { + ms, _, _ := procGetTickCount64.Call() + return int64(ms) / 1000 +} + +func getCPUInfo() CPUInfo { + info := CPUInfo{} + + // Modell aus Registry + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE) + if err == nil { + defer k.Close() + info.Model, _, _ = k.GetStringValue("ProcessorNameString") + info.Model = strings.TrimSpace(info.Model) + } + + // Kern-Anzahl + info.LogicalCores = getLogicalCoreCount() + info.Cores = getPhysicalCoreCount() + if info.Cores == 0 { + info.Cores = info.LogicalCores + } + + // CPU-Last via PowerShell (einmalig, nicht ideal aber zuverlässig) + info.LoadPercent = getCPULoad() + + return info +} + +type systemInfo struct { + wProcessorArchitecture uint16 + wReserved uint16 + dwPageSize uint32 + lpMinimumApplicationAddress uintptr + lpMaximumApplicationAddress uintptr + dwActiveProcessorMask uintptr + dwNumberOfProcessors uint32 + dwProcessorType uint32 + dwAllocationGranularity uint32 + wProcessorLevel uint16 + wProcessorRevision uint16 +} + +func getLogicalCoreCount() int { + var si systemInfo + kernel32.NewProc("GetSystemInfo").Call(uintptr(unsafe.Pointer(&si))) + return int(si.dwNumberOfProcessors) +} + +func getPhysicalCoreCount() int { + out, err := runPS("(Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfCores -Sum).Sum") + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(out)) + return n +} + +func getCPULoad() float64 { + // GetSystemTimes: idle, kernel (includes idle), user + type filetime struct{ lo, hi uint32 } + var idle, kernel, user filetime + + r, _, _ := procGetSystemTimes.Call( + uintptr(unsafe.Pointer(&idle)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if r == 0 { + return 0 + } + + toUint64 := func(f filetime) uint64 { return uint64(f.hi)<<32 | uint64(f.lo) } + + t1idle := toUint64(idle) + t1kernel := toUint64(kernel) + t1user := toUint64(user) + + time.Sleep(500 * time.Millisecond) + + r, _, _ = procGetSystemTimes.Call( + uintptr(unsafe.Pointer(&idle)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if r == 0 { + return 0 + } + + t2idle := toUint64(idle) + t2kernel := toUint64(kernel) + t2user := toUint64(user) + + idleDelta := t2idle - t1idle + kernelDelta := t2kernel - t1kernel + userDelta := t2user - t1user + + total := kernelDelta + userDelta + if total == 0 { + return 0 + } + busy := total - idleDelta + return float64(busy) / float64(total) * 100.0 +} + +func getMemoryInfo() MemoryInfo { + var ms memoryStatusEx + ms.dwLength = uint32(unsafe.Sizeof(ms)) + procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))) + + used := ms.ullTotalPhys - ms.ullAvailPhys + usedPct := 0.0 + if ms.ullTotalPhys > 0 { + usedPct = float64(used) / float64(ms.ullTotalPhys) * 100.0 + } + return MemoryInfo{ + TotalBytes: ms.ullTotalPhys, + AvailableBytes: ms.ullAvailPhys, + UsedPercent: usedPct, + } +} + +func getDiskInfo() []DiskInfo { + var disks []DiskInfo + + // Alle logischen Laufwerke ermitteln + buf := make([]uint16, 256) + kernel32.NewProc("GetLogicalDriveStringsW").Call( + uintptr(len(buf)), + uintptr(unsafe.Pointer(&buf[0])), + ) + + // buf enthält null-separierte Strings ("C:\\\0D:\\\0\0") + drives := windows.UTF16ToString(buf) + for _, drive := range strings.Split(drives, "\x00") { + drive = strings.TrimSpace(drive) + if len(drive) < 2 { + continue + } + + // Nur lokale Festplatten (DRIVE_FIXED = 3) + driveType := kernel32.NewProc("GetDriveTypeW") + drivePath := windows.StringToUTF16Ptr(drive) + t, _, _ := driveType.Call(uintptr(unsafe.Pointer(drivePath))) + if t != 3 { // DRIVE_FIXED + continue + } + + // Speicherplatz + var freeBytesToCaller, totalBytes, freeBytes uint64 + kernel32.NewProc("GetDiskFreeSpaceExW").Call( + uintptr(unsafe.Pointer(drivePath)), + uintptr(unsafe.Pointer(&freeBytesToCaller)), + uintptr(unsafe.Pointer(&totalBytes)), + uintptr(unsafe.Pointer(&freeBytes)), + ) + + if totalBytes == 0 { + continue + } + + usedPct := float64(totalBytes-freeBytes) / float64(totalBytes) * 100.0 + + // Laufwerksbuchstabe ohne Backslash + letter := strings.TrimSuffix(strings.TrimSuffix(drive, `\`), "/") + + disks = append(disks, DiskInfo{ + Drive: letter, + TotalBytes: totalBytes, + FreeBytes: freeBytes, + UsedPercent: usedPct, + }) + } + return disks +} + +func getDomain() string { + out, err := runPS("(Get-WmiObject Win32_ComputerSystem).Domain") + if err != nil { + return "" + } + d := strings.TrimSpace(out) + if d == "WORKGROUP" { + return "" + } + return d +} + +// ToJSON serialisiert SystemData für den Heartbeat +func (s *SystemData) ToJSON() (json.RawMessage, error) { + b, err := json.Marshal(s) + return json.RawMessage(b), err +} + +// runPS führt einen PowerShell-Befehl aus und gibt stdout zurück +func runPS(cmd string) (string, error) { + out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd).Output() + if err != nil { + log.Printf("PowerShell-Fehler (%s): %v", cmd[:min(len(cmd), 40)], err) + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/agent-windows/config.yaml.example b/agent-windows/config.yaml.example new file mode 100644 index 0000000..1ae91ba --- /dev/null +++ b/agent-windows/config.yaml.example @@ -0,0 +1,5 @@ +backend_url: "https://192.168.85.13:8443" +api_key: "AGENT_KEY_HERE" +agent_name: "PC-Name" +interval_seconds: 60 +insecure: true diff --git a/agent-windows/config/config.go b/agent-windows/config/config.go new file mode 100644 index 0000000..410e06a --- /dev/null +++ b/agent-windows/config/config.go @@ -0,0 +1,30 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + BackendURL string `yaml:"backend_url"` + APIKey string `yaml:"api_key"` + AgentName string `yaml:"agent_name"` + IntervalSeconds int `yaml:"interval_seconds"` + Insecure bool `yaml:"insecure"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cfg := &Config{ + IntervalSeconds: 60, + Insecure: true, + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/agent-windows/go.mod b/agent-windows/go.mod new file mode 100644 index 0000000..386064b --- /dev/null +++ b/agent-windows/go.mod @@ -0,0 +1,11 @@ +module github.com/cynfo/rmm-agent-windows + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.1 + golang.org/x/sys v0.18.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/net v0.17.0 // indirect diff --git a/agent-windows/go.sum b/agent-windows/go.sum new file mode 100644 index 0000000..0692992 --- /dev/null +++ b/agent-windows/go.sum @@ -0,0 +1,10 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent-windows/install.ps1 b/agent-windows/install.ps1 new file mode 100644 index 0000000..98540d3 --- /dev/null +++ b/agent-windows/install.ps1 @@ -0,0 +1,86 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + RMM Windows Agent Installer +.EXAMPLE + .\install.ps1 -BackendURL "https://192.168.85.13:8443" -APIKey "abc123" -AgentName "PC-Muster" +#> +param( + [Parameter(Mandatory=$true)] [string]$BackendURL, + [Parameter(Mandatory=$true)] [string]$APIKey, + [Parameter(Mandatory=$false)] [string]$AgentName = $env:COMPUTERNAME, + [Parameter(Mandatory=$false)] [switch]$Uninstall +) + +$InstallDir = "C:\Program Files\RMMAgent" +$BinaryName = "rmm-agent-windows.exe" +$ServiceName = "RMMAgent" +$ConfigFile = "$InstallDir\config.yaml" + +if ($Uninstall) { + Write-Host "Deinstalliere $ServiceName..." + if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + & "$InstallDir\$BinaryName" -uninstall + } + Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "Deinstallation abgeschlossen." + exit 0 +} + +# Verzeichnis anlegen +Write-Host "Erstelle Installationsverzeichnis: $InstallDir" +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + +# Binary kopieren (muss im gleichen Ordner wie das Install-Script liegen) +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SourceBinary = Join-Path $ScriptDir $BinaryName + +if (-not (Test-Path $SourceBinary)) { + Write-Error "Binary nicht gefunden: $SourceBinary" + exit 1 +} + +Write-Host "Kopiere Binary nach $InstallDir..." +Copy-Item -Path $SourceBinary -Destination "$InstallDir\$BinaryName" -Force + +# Konfiguration erstellen +Write-Host "Erstelle Konfiguration..." +$Config = @" +backend_url: "$BackendURL" +api_key: "$APIKey" +agent_name: "$AgentName" +interval_seconds: 60 +insecure: true +"@ +$Config | Out-File -FilePath $ConfigFile -Encoding UTF8 -Force + +# Dienst installieren +Write-Host "Installiere Windows-Dienst..." +if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { + Write-Host "Dienst vorhanden — stoppe und entferne alten Dienst..." + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + & "$InstallDir\$BinaryName" -uninstall + Start-Sleep -Seconds 2 +} + +& "$InstallDir\$BinaryName" -config $ConfigFile -install +if ($LASTEXITCODE -ne 0) { + Write-Error "Dienst-Installation fehlgeschlagen" + exit 1 +} + +# Dienst starten +Write-Host "Starte Dienst..." +Start-Service -Name $ServiceName +Start-Sleep -Seconds 2 + +$svc = Get-Service -Name $ServiceName +Write-Host "Dienst-Status: $($svc.Status)" + +if ($svc.Status -eq "Running") { + Write-Host "`nInstallation erfolgreich!" -ForegroundColor Green + Write-Host "Log: $InstallDir\rmm-agent.log" +} else { + Write-Warning "Dienst laeuft nicht — bitte Log pruefen: $InstallDir\rmm-agent.log" +} diff --git a/agent-windows/main.go b/agent-windows/main.go new file mode 100644 index 0000000..b20765f --- /dev/null +++ b/agent-windows/main.go @@ -0,0 +1,282 @@ +//go:build windows + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/cynfo/rmm-agent-windows/client" + "github.com/cynfo/rmm-agent-windows/collector" + "github.com/cynfo/rmm-agent-windows/config" + "github.com/cynfo/rmm-agent-windows/ws" +) + +var Version = "1.0.0" + +const ( + ServiceName = "RMMAgent" + ServiceDisplayName = "RMM Agent" + ServiceDescription = "RMM Agent - Remote Monitoring & Management" + AgentIDFile = "agent_id.txt" +) + +// agentService implementiert das Windows Service Interface +type agentService struct { + cfg *config.Config + cfgPath string +} + +func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + changes <- svc.Status{State: svc.StartPending} + stop := make(chan struct{}) + go runAgent(s.cfg, s.cfgPath, stop) + changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + + for c := range r { + switch c.Cmd { + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending} + close(stop) + return false, 0 + } + } + return false, 0 +} + +func main() { + var ( + cfgPath = flag.String("config", defaultConfigPath(), "Pfad zur Konfigurationsdatei") + flagDebug = flag.Bool("debug", false, "Im Vordergrund ausfuehren (kein Dienst)") + flagInst = flag.Bool("install", false, "Als Windows-Dienst installieren") + flagUninst = flag.Bool("uninstall", false, "Windows-Dienst deinstallieren") + ) + flag.Parse() + + // Logging in Datei (neben Binary) + logPath := filepath.Join(filepath.Dir(*cfgPath), "rmm-agent.log") + if lf, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + log.SetOutput(lf) + } + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Printf("RMM Windows Agent v%s startet", Version) + + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("Konfiguration laden fehlgeschlagen (%s): %v", *cfgPath, err) + } + + if *flagInst { + installService(cfg, *cfgPath) + return + } + if *flagUninst { + uninstallService() + return + } + + // Interaktiv (debug) oder als Dienst? + isInteractive, err := svc.IsAnInteractiveSession() + if err != nil { + log.Printf("IsAnInteractiveSession: %v", err) + isInteractive = true + } + + if *flagDebug || isInteractive { + log.Println("Starte im Debug-Modus (Vordergrund)") + stop := make(chan struct{}) + runAgent(cfg, *cfgPath, stop) + } else { + if err := svc.Run(ServiceName, &agentService{cfg: cfg, cfgPath: *cfgPath}); err != nil { + log.Fatalf("Dienst-Fehler: %v", err) + } + } +} + +// runAgent ist die Hauptschleife des Agents +func runAgent(cfg *config.Config, cfgPath string, stop <-chan struct{}) { + c := client.New(cfg.BackendURL, cfg.APIKey, cfg.Insecure) + + // Agent-ID laden oder registrieren + agentID, err := loadOrRegister(c, cfg, cfgPath) + if err != nil { + log.Fatalf("Registrierung fehlgeschlagen: %v", err) + } + log.Printf("Agent-ID: %s", agentID) + + // WS-Handler und -Client starten + handler := ws.NewHandler() + wsClient := ws.NewClient(cfg.BackendURL, agentID, cfg.APIKey, cfg.Insecure, handler) + go wsClient.Run(stop) + + // Heartbeat-Schleife + interval := time.Duration(cfg.IntervalSeconds) * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Ersten Heartbeat sofort + sendHeartbeat(c, agentID) + + for { + select { + case <-stop: + log.Println("Agent wird gestoppt") + return + case <-ticker.C: + sendHeartbeat(c, agentID) + } + } +} + +func sendHeartbeat(c *client.Client, agentID string) { + sysData, err := collector.Collect() + if err != nil { + log.Printf("Collector-Fehler: %v", err) + } + + var rawData json.RawMessage + if sysData != nil { + rawData, _ = sysData.ToJSON() + } + + resp, err := c.Heartbeat(client.HeartbeatRequest{ + AgentID: agentID, + AgentVersion: Version, + Platform: "windows", + SystemData: rawData, + }) + if err != nil { + log.Printf("Heartbeat fehlgeschlagen: %v", err) + return + } + log.Printf("Heartbeat OK") + + if resp.UpdateAvailable { + log.Printf("Update verfuegbar: v%s — OTA-Update noch nicht implementiert", resp.UpdateVersion) + // TODO: OTA-Updater implementieren (aehnlich rmm-updater auf Linux) + } +} + +// loadOrRegister liest die Agent-ID aus Datei oder registriert sich neu +func loadOrRegister(c *client.Client, cfg *config.Config, cfgPath string) (string, error) { + idFile := filepath.Join(filepath.Dir(cfgPath), AgentIDFile) + + if data, err := os.ReadFile(idFile); err == nil { + id := string(data) + if len(id) > 0 { + // Re-registrieren um sicherzustellen dass der Agent im Backend bekannt ist + hostname, _ := os.Hostname() + c.Register(client.RegisterRequest{ + AgentID: id, + Name: cfg.AgentName, + Hostname: hostname, + IP: "", + AgentVersion: Version, + Platform: "windows", + }) + return id, nil + } + } + + // Neu registrieren + hostname, _ := os.Hostname() + name := cfg.AgentName + if name == "" { + name = hostname + } + id, err := c.Register(client.RegisterRequest{ + Name: name, + Hostname: hostname, + AgentVersion: Version, + Platform: "windows", + }) + if err != nil { + return "", fmt.Errorf("Registrierung: %w", err) + } + + if err := os.WriteFile(idFile, []byte(id), 0644); err != nil { + log.Printf("Agent-ID speichern fehlgeschlagen: %v", err) + } + log.Printf("Neu registriert als: %s", id) + return id, nil +} + +// defaultConfigPath gibt den Standardpfad zur Konfigurationsdatei zurück +func defaultConfigPath() string { + exe, err := os.Executable() + if err != nil { + return `C:\Program Files\RMMAgent\config.yaml` + } + return filepath.Join(filepath.Dir(exe), "config.yaml") +} + +// installService installiert den Agent als Windows-Dienst +func installService(cfg *config.Config, cfgPath string) { + m, err := mgr.Connect() + if err != nil { + log.Fatalf("Service Manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err == nil { + s.Close() + log.Fatalf("Dienst '%s' existiert bereits — zuerst deinstallieren", ServiceName) + } + + exe, err := os.Executable() + if err != nil { + log.Fatalf("Executable-Pfad: %v", err) + } + + s, err = m.CreateService(ServiceName, exe, mgr.Config{ + DisplayName: ServiceDisplayName, + Description: ServiceDescription, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + }, "-config", cfgPath) + if err != nil { + log.Fatalf("Dienst erstellen: %v", err) + } + defer s.Close() + + // Event Log registrieren + eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info) + + log.Printf("Dienst '%s' installiert", ServiceName) + log.Println("Starten mit: net start RMMAgent") +} + +// uninstallService entfernt den Windows-Dienst +func uninstallService() { + m, err := mgr.Connect() + if err != nil { + log.Fatalf("Service Manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err != nil { + log.Fatalf("Dienst nicht gefunden: %v", err) + } + defer s.Close() + + s.Control(svc.Stop) + time.Sleep(2 * time.Second) + + if err := s.Delete(); err != nil { + log.Fatalf("Dienst loeschen: %v", err) + } + + eventlog.Remove(ServiceName) + log.Printf("Dienst '%s' deinstalliert", ServiceName) +} diff --git a/agent-windows/rmm-agent-windows.exe b/agent-windows/rmm-agent-windows.exe new file mode 100755 index 0000000..a5c9e08 Binary files /dev/null and b/agent-windows/rmm-agent-windows.exe differ diff --git a/agent-windows/ws/client.go b/agent-windows/ws/client.go new file mode 100644 index 0000000..b0ce82c --- /dev/null +++ b/agent-windows/ws/client.go @@ -0,0 +1,129 @@ +package ws + +import ( + "crypto/tls" + "fmt" + "log" + "net/url" + "time" + + "github.com/gorilla/websocket" +) + +type Client struct { + backendURL string + agentID string + apiKey string + insecure bool + conn *websocket.Conn + handler *Handler + reconnectWait time.Duration +} + +func NewClient(backendURL, agentID, apiKey string, insecure bool, handler *Handler) *Client { + return &Client{ + backendURL: backendURL, + agentID: agentID, + apiKey: apiKey, + insecure: insecure, + handler: handler, + reconnectWait: 5 * time.Second, + } +} + +func (c *Client) Run(stop <-chan struct{}) { + for { + select { + case <-stop: + return + default: + } + + if err := c.connect(stop); err != nil { + log.Printf("WS-Verbindung fehlgeschlagen: %v — erneuter Versuch in %v", err, c.reconnectWait) + select { + case <-stop: + return + case <-time.After(c.reconnectWait): + } + if c.reconnectWait < 60*time.Second { + c.reconnectWait *= 2 + } + } else { + c.reconnectWait = 5 * time.Second + } + } +} + +func (c *Client) connect(stop <-chan struct{}) error { + u, err := url.Parse(c.backendURL) + if err != nil { + return err + } + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + } + u.Path = "/api/v1/agent/ws" + q := u.Query() + q.Set("agent_id", c.agentID) + q.Set("api_key", c.apiKey) + u.RawQuery = q.Encode() + + dialer := websocket.DefaultDialer + if c.insecure { + dialer = &websocket.Dialer{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + HandshakeTimeout: 10 * time.Second, + } + } + + conn, _, err := dialer.Dial(u.String(), nil) + if err != nil { + return fmt.Errorf("Dial fehlgeschlagen: %w", err) + } + defer conn.Close() + c.conn = conn + log.Printf("WS verbunden mit %s", c.backendURL) + + conn.SetReadDeadline(time.Now().Add(70 * time.Second)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(70 * time.Second)) + return nil + }) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + log.Printf("WS Read-Fehler: %v", err) + } + return + } + if resp := c.handler.Handle(msg); resp != nil { + conn.WriteMessage(websocket.TextMessage, resp) + } + } + }() + + select { + case <-stop: + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + return nil + case <-done: + return fmt.Errorf("Verbindung getrennt") + } +} + +func (c *Client) SendMessage(data []byte) error { + if c.conn == nil { + return fmt.Errorf("nicht verbunden") + } + return c.conn.WriteMessage(websocket.TextMessage, data) +} diff --git a/agent-windows/ws/handler.go b/agent-windows/ws/handler.go new file mode 100644 index 0000000..569435d --- /dev/null +++ b/agent-windows/ws/handler.go @@ -0,0 +1,277 @@ +package ws + +import ( + "encoding/json" + "fmt" + "log" + "os/exec" + "strings" + "time" +) + +type Message struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Command string `json:"command,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type Handler struct{} + +func NewHandler() *Handler { + return &Handler{} +} + +func (h *Handler) Handle(raw []byte) []byte { + var msg Message + if err := json.Unmarshal(raw, &msg); err != nil { + log.Printf("WS Parse-Fehler: %v", err) + return nil + } + if msg.Type != "command" { + return nil + } + + var resp Message + switch msg.Command { + case "exec": + resp = h.handleExec(msg) + case "winget_list": + resp = h.handleWingetList(msg) + case "winget_install": + resp = h.handleWingetInstall(msg) + case "winget_upgrade": + resp = h.handleWingetUpgrade(msg) + case "winget_upgrade_all": + resp = h.handleWingetUpgradeAll(msg) + case "windows_updates_check": + resp = h.handleWinUpdatesCheck(msg) + case "windows_updates_install": + resp = h.handleWinUpdatesInstall(msg) + case "reboot": + resp = h.handleReboot(msg) + default: + resp = Message{Type: "response", ID: msg.ID, Status: "error", + Error: fmt.Sprintf("Unbekanntes Command: %s", msg.Command)} + } + + out, _ := json.Marshal(resp) + return out +} + +// handleExec führt einen beliebigen Befehl aus (PowerShell oder cmd) +func (h *Handler) handleExec(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + + data, _ := msg.Data.(map[string]interface{}) + command, _ := data["command"].(string) + if command == "" { + resp.Status = "error" + resp.Error = "Kein Befehl angegeben" + return resp + } + + timeoutSecs := 60 + if t, ok := data["timeout"].(float64); ok && t > 0 { + timeoutSecs = int(t) + } + + out, err := runWithTimeout(command, timeoutSecs) + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + resp.Data = map[string]interface{}{"output": out} + } else { + resp.Status = "ok" + resp.Data = map[string]interface{}{"output": out} + } + return resp +} + +// handleWingetList listet installierte Software auf +func (h *Handler) handleWingetList(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + out, err := runPS("winget list --accept-source-agreements 2>&1") + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + return resp + } + resp.Status = "ok" + resp.Data = map[string]interface{}{"output": out} + return resp +} + +// handleWingetInstall installiert ein Paket +func (h *Handler) handleWingetInstall(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + data, _ := msg.Data.(map[string]interface{}) + pkg, _ := data["package"].(string) + if pkg == "" { + resp.Status = "error" + resp.Error = "Kein Paket angegeben" + return resp + } + cmd := fmt.Sprintf("winget install --id %s --silent --accept-package-agreements --accept-source-agreements 2>&1", sanitize(pkg)) + out, err := runWithTimeout(cmd, 300) + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + resp.Data = map[string]interface{}{"output": out} + } else { + resp.Status = "ok" + resp.Data = map[string]interface{}{"output": out} + } + return resp +} + +// handleWingetUpgrade aktualisiert ein Paket +func (h *Handler) handleWingetUpgrade(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + data, _ := msg.Data.(map[string]interface{}) + pkg, _ := data["package"].(string) + if pkg == "" { + resp.Status = "error" + resp.Error = "Kein Paket angegeben" + return resp + } + cmd := fmt.Sprintf("winget upgrade --id %s --silent --accept-package-agreements --accept-source-agreements 2>&1", sanitize(pkg)) + out, err := runWithTimeout(cmd, 300) + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + } else { + resp.Status = "ok" + resp.Data = map[string]interface{}{"output": out} + } + return resp +} + +// handleWingetUpgradeAll aktualisiert alle Pakete +func (h *Handler) handleWingetUpgradeAll(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + out, err := runWithTimeout("winget upgrade --all --silent --accept-package-agreements --accept-source-agreements 2>&1", 600) + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + } else { + resp.Status = "ok" + resp.Data = map[string]interface{}{"output": out} + } + return resp +} + +// handleWinUpdatesCheck prüft verfügbare Windows-Updates +func (h *Handler) handleWinUpdatesCheck(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + script := ` +$updateSession = New-Object -ComObject Microsoft.Update.Session +$searcher = $updateSession.CreateUpdateSearcher() +$result = $searcher.Search("IsInstalled=0 and Type='Software'") +$updates = @() +foreach ($u in $result.Updates) { + $updates += [PSCustomObject]@{ + Title = $u.Title + KB = ($u.KBArticleIDs | Select-Object -First 1) + Size = $u.MaxDownloadSize + } +} +$updates | ConvertTo-Json -Compress` + out, err := runPSScript(script, 120) + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + return resp + } + resp.Status = "ok" + resp.Data = map[string]interface{}{"raw": out} + return resp +} + +// handleWinUpdatesInstall installiert alle ausstehenden Updates +func (h *Handler) handleWinUpdatesInstall(msg Message) Message { + resp := Message{Type: "response", ID: msg.ID} + script := ` +$updateSession = New-Object -ComObject Microsoft.Update.Session +$searcher = $updateSession.CreateUpdateSearcher() +$result = $searcher.Search("IsInstalled=0 and Type='Software'") +if ($result.Updates.Count -eq 0) { Write-Output "Keine Updates verfuegbar"; exit 0 } +$downloader = $updateSession.CreateUpdateDownloader() +$downloader.Updates = $result.Updates +$downloader.Download() +$installer = $updateSession.CreateUpdateInstaller() +$installer.Updates = $result.Updates +$installResult = $installer.Install() +Write-Output "Installiert: $($result.Updates.Count) Updates, ResultCode: $($installResult.ResultCode)"` + out, err := runPSScript(script, 1800) // 30 Min Timeout + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + resp.Data = map[string]interface{}{"output": out} + } else { + resp.Status = "ok" + resp.Data = map[string]interface{}{"output": out, "reboot_required": strings.Contains(out, "ResultCode: 2")} + } + return resp +} + +func (h *Handler) handleReboot(msg Message) Message { + go func() { + time.Sleep(3 * time.Second) + exec.Command("shutdown", "/r", "/t", "10", "/c", "RMM-gesteuert").Run() + }() + return Message{Type: "response", ID: msg.ID, Status: "ok", + Data: map[string]interface{}{"message": "Neustart in 10 Sekunden"}} +} + +// Hilfsfunktionen + +func runWithTimeout(command string, timeoutSecs int) (string, error) { + cmd := exec.Command("cmd", "/C", command) + cmd.SysProcAttr = nil + out, err := withTimeout(cmd, time.Duration(timeoutSecs)*time.Second) + return out, err +} + +func runPS(command string) (string, error) { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command) + b, err := cmd.CombinedOutput() + return strings.TrimSpace(string(b)), err +} + +func runPSScript(script string, timeoutSecs int) (string, error) { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) + out, err := withTimeout(cmd, time.Duration(timeoutSecs)*time.Second) + return out, err +} + +func withTimeout(cmd *exec.Cmd, timeout time.Duration) (string, error) { + done := make(chan struct{}) + var out []byte + var err error + go func() { + out, err = cmd.CombinedOutput() + close(done) + }() + select { + case <-done: + return strings.TrimSpace(string(out)), err + case <-time.After(timeout): + cmd.Process.Kill() + return "", fmt.Errorf("Timeout nach %v", timeout) + } +} + +func sanitize(s string) string { + // Einfache Bereinigung — nur alphanumerisch, Punkt, Bindestrich + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' { + b.WriteRune(r) + } + } + return b.String() +}