feat: Windows Agent v1.0.0

- Windows-Dienst (golang.org/x/sys/windows/svc)
- Collector: CPU (GetSystemTimes), RAM (GlobalMemoryStatusEx), Disks (GetDiskFreeSpaceEx)
- WS-Client: gleiche Verbindungslogik wie Linux/BSD Agent
- Commands: exec, winget list/install/upgrade/upgrade-all, Windows Updates check/install, reboot
- install.ps1: automatische Service-Installation mit Parametern
- make agent-windows VERSION=x.x.x
This commit is contained in:
cynfo3000 2026-03-10 01:22:19 +01:00
parent 1c7c82dfac
commit 8116b71b48
12 changed files with 1293 additions and 1 deletions

View File

@ -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) .

View File

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

View File

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

View File

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

View File

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

11
agent-windows/go.mod Normal file
View File

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

10
agent-windows/go.sum Normal file
View File

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

86
agent-windows/install.ps1 Normal file
View File

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

282
agent-windows/main.go Normal file
View File

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

Binary file not shown.

129
agent-windows/ws/client.go Normal file
View File

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

277
agent-windows/ws/handler.go Normal file
View File

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