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
|
VERSION ?= 0.0.0
|
||||||
|
|
||||||
BACKEND_BIN = build/rmm-backend
|
BACKEND_BIN = build/rmm-backend
|
||||||
AGENT_BIN = build/rmm-agent
|
AGENT_BIN = build/rmm-agent
|
||||||
AGENT_LINUX_BIN = build/rmm-agent-linux
|
AGENT_LINUX_BIN = build/rmm-agent-linux
|
||||||
|
AGENT_WINDOWS_BIN = build/rmm-agent-windows.exe
|
||||||
UPDATER_FREEBSD_BIN = build/rmm-updater-freebsd
|
UPDATER_FREEBSD_BIN = build/rmm-updater-freebsd
|
||||||
UPDATER_LINUX_BIN = build/rmm-updater-linux
|
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) .
|
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"
|
@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:
|
updater-freebsd:
|
||||||
@echo "==> Building Updater (freebsd/amd64)..."
|
@echo "==> Building Updater (freebsd/amd64)..."
|
||||||
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_FREEBSD_BIN) .
|
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