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:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user