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:
parent
1c7c82dfac
commit
8116b71b48
8
Makefile
8
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) .
|
||||
|
||||
102
agent-windows/client/client.go
Normal file
102
agent-windows/client/client.go
Normal 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)
|
||||
}
|
||||
354
agent-windows/collector/system.go
Normal file
354
agent-windows/collector/system.go
Normal 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
|
||||
}
|
||||
5
agent-windows/config.yaml.example
Normal file
5
agent-windows/config.yaml.example
Normal 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
|
||||
30
agent-windows/config/config.go
Normal file
30
agent-windows/config/config.go
Normal 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
11
agent-windows/go.mod
Normal 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
10
agent-windows/go.sum
Normal 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
86
agent-windows/install.ps1
Normal 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
282
agent-windows/main.go
Normal 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)
|
||||
}
|
||||
BIN
agent-windows/rmm-agent-windows.exe
Executable file
BIN
agent-windows/rmm-agent-windows.exe
Executable file
Binary file not shown.
129
agent-windows/ws/client.go
Normal file
129
agent-windows/ws/client.go
Normal 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
277
agent-windows/ws/handler.go
Normal 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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user