Compare commits

...

42 Commits

Author SHA1 Message Date
cynfo3000
54ddc0b902 WAU Compliance: agent collector, backend endpoints, frontend badge + panel
- Agent: WAU registry config check (include/exclude URLs), pending updates count
- Backend: /wau/compliance per customer, /wau/compliance/summary all customers
- Frontend: compliance badge in customer list, compliance panel in WAU tab
- config.js: no longer gitignored (secrets-free, reads from env/window.__RMM_CONFIG__)
- vite.config.js: reads backend URL from .env instead of hardcoded value
- .env.example added for clean setup
2026-03-10 17:38:58 +01:00
cynfo3000
bf51ed8569 feat: WAU-Integration + Windows Software-Inventar (v1.1.0)
Windows Agent (v1.1.0):
- Software-Inventar aus Windows-Registry (64-bit + 32-bit)
  Sammelt Name, Version, Hersteller, InstallDate aller installierten Apps
  Filtert SystemComponent-Einträge heraus, sortiert alphabetisch
  Funktioniert unter SYSTEM-Account (kein winget nötig)
- Version auf 1.1.0 gehoben

Backend:
- customer_wau_rules Tabelle (Migration automatisch)
- WAU-Regeln pro Kunde (allow/block, winget-ID + Anzeigename)
- GET/POST/DELETE /api/v1/customers/{id}/wau/rules
- GET /api/v1/customers/{id}/wau/inventory  (SQL-Aggregation aus Agenten-Daten)
- GET /api/v1/customers/{id}/wau/included   (Plaintext für WAU)
- GET /api/v1/customers/{id}/wau/excluded   (Plaintext für WAU)

Frontend:
- Kunden-Seite: Tab-Navigation (Agents | API-Keys | WAU)
- WAU-Tab pro Kunde: Allowlist + Blocklist mit Inline-Assign aus Inventar
  Allow/Block-Buttons direkt in Inventar-Zeile, winget-ID vorgeschlagen
  Installationsbefehl mit externer Backend-URL aus Systemeinstellungen
- Windows-Panel Software-Tab: Installiert-Liste aus Registry-Daten
- Windows-Panel WAU-Tab: Status (installiert/Task/letzter Lauf)
  WAU installieren via MSI-Download + msiexec (synchron)
  Jetzt ausführen, Log anzeigen
  Gewünschten Zustand herstellen (Allowlist auf Client installieren)
2026-03-10 08:46:00 +01:00
cynfo3000
7a5f4c0564 fix: WS-Client Ping-Loop (30s) — Verbindung bricht nicht mehr nach 70s ab 2026-03-10 02:42:34 +01:00
cynfo3000
ef567874c6 fix: Win11 OS-Erkennung via Build >= 22000, v1.0.6 2026-03-10 02:34:57 +01:00
cynfo3000
154d1bd8c8 fix: handleExec ersetzt winget durch vollen Pfad automatisch 2026-03-10 02:24:59 +01:00
cynfo3000
0f496a54e7 fix: winget-Pfad dynamisch suchen (Service laeuft als SYSTEM ohne WindowsApps im PATH) 2026-03-10 02:22:06 +01:00
cynfo3000
a97b17a682 fix: -OutputEncoding entfernt (inkompatibel), KB null-safe, exec via PS 2026-03-10 02:17:05 +01:00
cynfo3000
557e59bfc5 fix: Windows handler liest params statt data, exec via PowerShell (winget-kompatibel) 2026-03-10 02:10:20 +01:00
cynfo3000
5eae071bf9 fix: CustomerAssign in WindowsPanel identisch mit AgentPanel (onChange direkt, kein OK-Button) 2026-03-10 02:07:32 +01:00
cynfo3000
1f8fda6a83 fix: Windows-Seite identisches Format wie Firewalls (Spaltenwahl, Sort, MiniBar) 2026-03-10 02:06:39 +01:00
cynfo3000
4e21fe3c4e feat: Windows-Seite in Navigation + Agents-Filter excludes Windows 2026-03-10 02:03:40 +01:00
cynfo3000
0e6154a0dd fix: system_data als RawJSON durchreichen — Windows-Daten nicht mehr weggeworfen 2026-03-10 01:56:09 +01:00
cynfo3000
e94fea3e91 feat: Windows Agent OTA-Updater
- PowerShell-Script-Methode: Binary herunterladen, detached PS1 tauscht Binary nach Dienst-Stop
- SHA256-Verifikation vor Update
- v1.0.1 im Firmware-Store
2026-03-10 01:52:16 +01:00
cynfo3000
2fe83e261a fix: heartbeat system_data als RawMessage — Windows/beliebige Plattformen ohne Datenverlust 2026-03-10 01:49:24 +01:00
cynfo3000
f7bbe3e4b1 fix: firmware/installer Windows support (label, badge, install cmd) 2026-03-10 01:39:51 +01:00
cynfo3000
fabc518bb8 fix: agent permission erlaubt installer/download 2026-03-10 01:34:50 +01:00
cynfo3000
346aea73b9 fix: installer endpoint erlaubt platform=windows 2026-03-10 01:32:52 +01:00
cynfo3000
4f71bd1520 feat: Windows Agent Frontend Panel
- WindowsPanel.jsx: Uebersicht (CPU/RAM/Disk), Software (winget), System
- Dashboard: Windows-Agents mit WIN-Badge, oeffnet WindowsPanel
- Gleiche Navigation wie OPNsense/Proxmox (Sidebar-Menue)
2026-03-10 01:28:30 +01:00
cynfo3000
8116b71b48 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
2026-03-10 01:22:19 +01:00
cynfo3000
1c7c82dfac fix: agent permission erlaubt firmware + agents endpoints fuer rmm-updater OTA 2026-03-10 01:08:14 +01:00
cynfo3000
1923b28fa2 refactor: Firmware Agent-Liste kompakt und nach Kunde gruppiert 2026-03-10 01:02:34 +01:00
cynfo3000
16a7cd3f87 fix: ProxmoxPanel Sub-Navigation als linkes Sidebar-Menü wie AgentPanel 2026-03-10 00:58:21 +01:00
cynfo3000
67e15040fb feat: ProxmoxPanel mit verschachtelter Navigation wie AgentPanel
Haupt-Tabs: Uebersicht / Virtualisierung / Speicher / System
Sub-Tabs: VMs+Container | Storage+ZFS+Backups+PBS | Dienste+Updates+Aufgaben+Tunnel+Agent
2026-03-10 00:53:37 +01:00
cynfo3000
81669c59a5 feat: kein automatischer read-Key mehr bei Kundenerstellung 2026-03-10 00:42:32 +01:00
cynfo3000
56cb19d052 feat: API-Keys in Einstellungen gruppiert nach Kunde 2026-03-10 00:36:31 +01:00
cynfo3000
cdb2443d8f refactor: API-Keys in Einstellungen kompakter (gleicher Stil wie Kunden-Panel) 2026-03-10 00:34:42 +01:00
cynfo3000
4023a14fe1 feat: Agent zeigt Warnung wenn er nicht den Kunden-Key nutzt
- last_api_key Spalte in agents, wird bei Heartbeat gesetzt
- GetAgentsByCustomer liefert last_api_key mit
- Frontend: rot + Warnsymbol wenn Key abweicht vom Kunden-Agent-Key
- Grau wenn noch kein Heartbeat mit Key-Info
2026-03-10 00:23:59 +01:00
cynfo3000
20a976767b fix: getCustomerAgents liefert status + agent_version 2026-03-10 00:21:58 +01:00
cynfo3000
4a79305eee feat: Kunden-Panel zeigt Agents + API-Keys nebeneinander 2026-03-10 00:20:56 +01:00
cynfo3000
ebd8f1ebb8 fix: agent permission erlaubt /api/v1/agent/register (BSD Agent braucht das zum Start) 2026-03-10 00:15:17 +01:00
cynfo3000
97eefcde5d fix: Kopieren auf HTTP nutzt execCommand-Fallback (clipboard API braucht HTTPS) 2026-03-10 00:07:47 +01:00
cynfo3000
5cc3476479 feat: Kunden-Seite zeigt API-Keys beim Aufklappen
- Klick auf Kundenzeile klappt Key-Panel auf
- Zeigt Agent + Read-only Keys mit Permission-Badge
- Key teilweise maskiert, Kopier-Button vorhanden
- Backend: GET /api/v1/customers/{id}/apikeys
- DB: ListCustomerAPIKeys() nach customer_id gefiltert
2026-03-10 00:06:16 +01:00
cynfo3000
fba824da8b fix: Agent-Key braucht 'agent' Permission, nicht 'read'
- Auto-Erstellung bei neuem Kunden: jetzt agent-Key + read-Key
- agent: fuer OPNsense/Linux Agents (WS + Heartbeat)
- read:  fuer externe Lesezugriffe / Dashboards
- Agent-Keys fuer alle 4 Kunden nachtraglich erstellt
2026-03-10 00:03:19 +01:00
cynfo3000
15ad9be8e7 feat: Auto Read-only API-Key bei Kunden-Erstellung
- createCustomer erstellt automatisch einen customer-scoped Read-only Key
- Response gibt Customer + neuen API-Key zurueck
- Bestehende 4 Kunden Keys nachtraglich erstellt
2026-03-09 23:56:47 +01:00
cynfo3000
f658f3c4db fix: deleteAPIKey this.del statt this.delete (JS-Fehler) 2026-03-09 23:55:45 +01:00
cynfo3000
ec30be7246 security: API-Key Permissions + Customer-Scoping
- DB Migration: api_keys bekommt 'permissions' + 'customer_id' Spalten
- Permissions: agent | read | write | admin
  - agent:  nur /api/v1/agent/ws + /api/v1/agent/heartbeat
  - read:   GET-Endpoints (optional customer-scoped)
  - write:  alles ausser Terminal
  - admin:  voll (Terminal bleibt JWT-only)
- Bestehende Keys werden auf 'admin' migriert (Abwaertskompatibilitaet)
- Frontend: API-Key Verwaltung zeigt Permissions-Badge + Kunden-Scope
- API-Client: createAPIKey nimmt jetzt permissions + customer_id
2026-03-09 23:52:26 +01:00
cynfo3000
91385b42d1 security: Terminal-Endpoint JWT-only (kein API-Key Zugriff)
- Neue JWTOnlyAuth Middleware: akzeptiert nur JWT, kein API-Key
- Terminal-Route aus allgemeinem CombinedAuth raus, eigener Mount
- API-Keys für Monitoring/Automation erlaubt, aber Terminal braucht
  echten Login mit User/Pass + 2FA
- ?token= Query-Param weiterhin erlaubt (Browser-WS kann kein Header setzen)
2026-03-09 23:44:12 +01:00
cynfo3000
e9a47bee26 fix: Terminal Auth per JWT-Token statt leerem API-Key
- TerminalModal nutzt jetzt JWT aus localStorage (api.getToken()) fuer WS-Auth
- Backend Middleware: ?token= Query-Parameter fuer WebSocket JWT-Auth
- Behebt: api_key= leer -> 401 -> Terminal nicht erreichbar
2026-03-09 23:39:38 +01:00
cynfo3000
358682e5f3 fix: Backend platform-aware Agent-Update (nicht mehr hardcoded freebsd) 2026-03-09 20:31:26 +01:00
cynfo3000
291d2e3c17 feat: VM/CT Start/Stop im Frontend + Agent proxmox_action Handler
- Agent: neuer 'proxmox_action' Command (start/stop/shutdown fuer VMs und LXC)
- Backend: POST /api/v1/agents/{id}/proxmox/action Endpoint
- Frontend: Start/Stop/Shutdown Buttons in VMs- und Container-Tab
  - Play (gruen) = starten (nur wenn gestoppt)
  - Power (gelb) = Graceful Shutdown (nur VMs, nur wenn running)
  - Stop (rot) = Hard Stop (wenn running)
- api/client.js: proxmoxAction() Methode
2026-03-09 20:22:45 +01:00
cynfo3000
4cd9764cec docs: config.example.js auf aktuelles Runtime-Config-Pattern gebracht 2026-03-09 19:11:58 +01:00
cynfo3000
682dd4183e docs: Debian 13 PostgreSQL-Versionskonflikt mit TimescaleDB dokumentiert
Debian 13 (Trixie) liefert PostgreSQL 17.8, TimescaleDB benoetigt >= 17.9.
Loesung: offizielles PostgreSQL-Apt-Repo (apt.postgresql.org/bookworm-pgdg)
statt Debian-eigene Pakete verwenden.
Fehlerbehebung fuer bestehende Installationen ebenfalls dokumentiert.
2026-03-08 21:14:18 +01:00
48 changed files with 5383 additions and 303 deletions

4
.gitignore vendored
View File

@ -34,8 +34,8 @@ updater/rmm-updater
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
# config.js hat echte IPs und API-Keys — nie einchecken # .env hat echte IPs und API-Keys — nie einchecken
frontend/src/config.js frontend/.env
!frontend/src/config.example.js !frontend/src/config.example.js
# ======================== # ========================

View File

@ -1,10 +1,11 @@
.PHONY: all backend agent agent-linux updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend .PHONY: all backend agent agent-linux agent-windows updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend
VERSION ?= 0.0.0 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) .

View File

@ -47,6 +47,8 @@ func (h *Handler) HandleCommand(msg Message) {
response = h.handleUpdate(msg) response = h.handleUpdate(msg)
case "zpoolscrub": case "zpoolscrub":
response = h.handleZpoolScrub(msg) response = h.handleZpoolScrub(msg)
case "proxmox_action":
response = h.handleProxmoxAction(msg)
case "pty_start": case "pty_start":
response = h.handlePTYStart(msg) response = h.handlePTYStart(msg)
case "pty_stop": case "pty_stop":
@ -481,3 +483,65 @@ func (h *Handler) handleZpoolScrub(msg Message) Message {
} }
return response return response
} }
// handleProxmoxAction: VM/CT starten oder stoppen via pvesh
func (h *Handler) handleProxmoxAction(msg Message) Message {
response := Message{Type: "response", ID: msg.ID}
vmType, _ := msg.Params["type"].(string) // "vm" oder "ct"
action, _ := msg.Params["action"].(string) // "start", "stop", "shutdown"
vmidFloat, ok := msg.Params["vmid"].(float64)
if !ok {
response.Status = "error"
response.Error = "Parameter 'vmid' erforderlich"
return response
}
vmid := int(vmidFloat)
if vmType != "vm" && vmType != "ct" {
response.Status = "error"
response.Error = "Parameter 'type' muss 'vm' oder 'ct' sein"
return response
}
if action != "start" && action != "stop" && action != "shutdown" {
response.Status = "error"
response.Error = "Parameter 'action' muss 'start', 'stop' oder 'shutdown' sein"
return response
}
// Node-Name ermitteln
nodeName, _ := os.Hostname()
if nodeName == "" {
nodeName = "localhost"
}
// pvesh-Pfad aufbauen
var apiPath string
if vmType == "vm" {
apiPath = fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", nodeName, vmid, action)
} else {
if action == "shutdown" {
action = "stop" // LXC kennt kein "shutdown", stop reicht
}
apiPath = fmt.Sprintf("/nodes/%s/lxc/%d/status/%s", nodeName, vmid, action)
}
log.Printf("Proxmox Action: pvesh create %s", apiPath)
cmd := exec.Command("/usr/bin/pvesh", "create", apiPath, "--output-format", "json")
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Proxmox Action Fehler: %v — %s", err, string(output))
response.Status = "error"
response.Error = fmt.Sprintf("pvesh fehlgeschlagen: %v — %s", err, strings.TrimSpace(string(output)))
return response
}
log.Printf("Proxmox Action erfolgreich: %s %s %d", action, vmType, vmid)
response.Status = "ok"
response.Data = map[string]interface{}{
"message": fmt.Sprintf("%s %d: %s ausgeführt", vmType, vmid, action),
"output": strings.TrimSpace(string(output)),
}
return response
}

View File

@ -0,0 +1,102 @@
package client
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
func New(baseURL, apiKey string, insecure bool) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
},
},
}
}
type RegisterRequest struct {
AgentID string `json:"agent_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
AgentVersion string `json:"agent_version,omitempty"`
Platform string `json:"platform"`
}
type RegisterResponse struct {
ID string `json:"id"`
Message string `json:"message"`
}
func (c *Client) Register(req RegisterRequest) (string, error) {
body, _ := json.Marshal(req)
resp, err := c.doRequest("POST", "/api/v1/agent/register", body)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Registrierung fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(data))
}
var r RegisterResponse
if err := json.Unmarshal(data, &r); err != nil {
return "", fmt.Errorf("JSON Parse: %v", err)
}
return r.ID, nil
}
type HeartbeatRequest struct {
AgentID string `json:"agent_id"`
AgentVersion string `json:"agent_version,omitempty"`
Platform string `json:"platform"`
SystemData json.RawMessage `json:"system_data,omitempty"`
}
type HeartbeatResponse struct {
Message string `json:"message"`
UpdateAvailable bool `json:"update_available"`
UpdateVersion string `json:"update_version,omitempty"`
UpdateHash string `json:"update_hash,omitempty"`
}
func (c *Client) Heartbeat(req HeartbeatRequest) (*HeartbeatResponse, error) {
body, _ := json.Marshal(req)
resp, err := c.doRequest("POST", "/api/v1/agent/heartbeat", body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Heartbeat fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(data))
}
var r HeartbeatResponse
json.Unmarshal(data, &r)
return &r, nil
}
func (c *Client) doRequest(method, path string, body []byte) (*http.Response, error) {
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", c.apiKey)
return c.httpClient.Do(req)
}

View File

@ -0,0 +1,448 @@
package collector
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"sort"
"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"`
InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"`
WAU *WAUInfo `json:"wau,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"`
}
type SoftwareInfo struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Publisher string `json:"publisher,omitempty"`
InstallDate string `json:"install_date,omitempty"`
}
// 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()
// Installierte Software (Registry)
wd.InstalledSoftware = getInstalledSoftware()
// WAU-Status (Installation + Konfiguration + Pending Updates)
wauInfo := getWAUInfo(wd.InstalledSoftware)
wd.WAU = &wauInfo
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")
// Windows 11 erkennen: Build >= 22000, aber ProductName sagt noch "Windows 10"
buildNum := 0
fmt.Sscanf(currentBuild, "%d", &buildNum)
if buildNum >= 22000 && strings.Contains(productName, "Windows 10") {
productName = strings.Replace(productName, "Windows 10", "Windows 11", 1)
}
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
}
// getInstalledSoftware liest installierte Software aus der Windows-Registry.
// Funktioniert auch unter SYSTEM-Account (kein winget noetig).
func getInstalledSoftware() []SoftwareInfo {
var result []SoftwareInfo
seen := make(map[string]bool)
regPaths := []struct {
root registry.Key
path string
}{
{registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`},
{registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
}
for _, rp := range regPaths {
k, err := registry.OpenKey(rp.root, rp.path, registry.READ|registry.ENUMERATE_SUB_KEYS)
if err != nil {
continue
}
subkeys, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil {
continue
}
for _, subkey := range subkeys {
sk, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
if err != nil {
continue
}
name, _, _ := sk.GetStringValue("DisplayName")
sk.Close()
if name == "" || seen[name] {
continue
}
seen[name] = true
// Nochmal öffnen für weitere Felder (getrennt um Close sauber zu halten)
sk2, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
if err != nil {
result = append(result, SoftwareInfo{Name: name})
continue
}
version, _, _ := sk2.GetStringValue("DisplayVersion")
publisher, _, _ := sk2.GetStringValue("Publisher")
installDate, _, _ := sk2.GetStringValue("InstallDate")
// SystemComponent überspringen (Windows-interne Komponenten)
sysComp, _, _ := sk2.GetIntegerValue("SystemComponent")
sk2.Close()
if sysComp == 1 {
continue
}
result = append(result, SoftwareInfo{
Name: name,
Version: version,
Publisher: publisher,
InstallDate: installDate,
})
}
}
sort.Slice(result, func(i, j int) bool {
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
})
return result
}

View File

@ -0,0 +1,100 @@
//go:build windows
package collector
import (
"os/exec"
"strings"
"golang.org/x/sys/windows/registry"
)
// WAUInfo enthält den Status von Winget-AutoUpdate auf diesem Gerät
type WAUInfo struct {
Installed bool `json:"installed"`
Version string `json:"version,omitempty"`
IncludeURL string `json:"include_url,omitempty"`
ExcludeURL string `json:"exclude_url,omitempty"`
PendingCount int `json:"pending_updates"`
}
// getWAUInfo liest WAU-Installations- und Konfigurationsstatus
func getWAUInfo(software []SoftwareInfo) WAUInfo {
info := WAUInfo{}
// WAU in installierter Software suchen
for _, s := range software {
lower := strings.ToLower(s.Name)
if strings.Contains(lower, "winget-autoupdate") || lower == "wau" {
info.Installed = true
info.Version = s.Version
break
}
}
// WAU-Konfiguration aus Registry lesen
// WAU speichert seine Einstellungen unter HKLM\SOFTWARE\Winget-AutoUpdate
regPaths := []string{
`SOFTWARE\Winget-AutoUpdate`,
`SOFTWARE\Policies\Microsoft\Windows\Winget-AutoUpdate`,
}
for _, path := range regPaths {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
if err != nil {
continue
}
if url, _, err := k.GetStringValue("WAU_IncludeListURL"); err == nil && url != "" {
info.IncludeURL = url
}
if url, _, err := k.GetStringValue("WAU_ExcludeListURL"); err == nil && url != "" {
info.ExcludeURL = url
}
k.Close()
if info.IncludeURL != "" || info.ExcludeURL != "" {
info.Installed = true
break
}
}
// Pending updates via winget upgrade --list zählen
wp := findWingetExe()
if wp != "" {
out, err := runPS(`& "` + wp + `" upgrade --list --accept-source-agreements 2>&1`)
if err == nil {
info.PendingCount = countWingetUpgradeLines(out)
}
}
return info
}
// findWingetExe sucht winget.exe im System
func findWingetExe() string {
if path, err := exec.LookPath("winget"); err == nil {
return path
}
script := `Get-ChildItem "C:\Program Files\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`
out, err := runPS(script)
if err == nil && strings.TrimSpace(out) != "" {
return strings.TrimSpace(out)
}
return ""
}
// countWingetUpgradeLines zählt Pakete mit Updates aus "winget upgrade --list" Output
func countWingetUpgradeLines(output string) int {
lines := strings.Split(output, "\n")
count := 0
pastSeparator := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "---") {
pastSeparator = true
continue
}
if pastSeparator && trimmed != "" {
count++
}
}
return count
}

View File

@ -0,0 +1,5 @@
backend_url: "https://192.168.85.13:8443"
api_key: "AGENT_KEY_HERE"
agent_name: "PC-Name"
interval_seconds: 60
insecure: true

View File

@ -0,0 +1,30 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
BackendURL string `yaml:"backend_url"`
APIKey string `yaml:"api_key"`
AgentName string `yaml:"agent_name"`
IntervalSeconds int `yaml:"interval_seconds"`
Insecure bool `yaml:"insecure"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := &Config{
IntervalSeconds: 60,
Insecure: true,
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}

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

@ -0,0 +1,11 @@
module github.com/cynfo/rmm-agent-windows
go 1.21
require (
github.com/gorilla/websocket v1.5.1
golang.org/x/sys v0.18.0
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/net v0.17.0 // indirect

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

@ -0,0 +1,10 @@
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@ -0,0 +1,86 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
RMM Windows Agent Installer
.EXAMPLE
.\install.ps1 -BackendURL "https://192.168.85.13:8443" -APIKey "abc123" -AgentName "PC-Muster"
#>
param(
[Parameter(Mandatory=$true)] [string]$BackendURL,
[Parameter(Mandatory=$true)] [string]$APIKey,
[Parameter(Mandatory=$false)] [string]$AgentName = $env:COMPUTERNAME,
[Parameter(Mandatory=$false)] [switch]$Uninstall
)
$InstallDir = "C:\Program Files\RMMAgent"
$BinaryName = "rmm-agent-windows.exe"
$ServiceName = "RMMAgent"
$ConfigFile = "$InstallDir\config.yaml"
if ($Uninstall) {
Write-Host "Deinstalliere $ServiceName..."
if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
& "$InstallDir\$BinaryName" -uninstall
}
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "Deinstallation abgeschlossen."
exit 0
}
# Verzeichnis anlegen
Write-Host "Erstelle Installationsverzeichnis: $InstallDir"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# Binary kopieren (muss im gleichen Ordner wie das Install-Script liegen)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$SourceBinary = Join-Path $ScriptDir $BinaryName
if (-not (Test-Path $SourceBinary)) {
Write-Error "Binary nicht gefunden: $SourceBinary"
exit 1
}
Write-Host "Kopiere Binary nach $InstallDir..."
Copy-Item -Path $SourceBinary -Destination "$InstallDir\$BinaryName" -Force
# Konfiguration erstellen
Write-Host "Erstelle Konfiguration..."
$Config = @"
backend_url: "$BackendURL"
api_key: "$APIKey"
agent_name: "$AgentName"
interval_seconds: 60
insecure: true
"@
$Config | Out-File -FilePath $ConfigFile -Encoding UTF8 -Force
# Dienst installieren
Write-Host "Installiere Windows-Dienst..."
if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
Write-Host "Dienst vorhanden — stoppe und entferne alten Dienst..."
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
& "$InstallDir\$BinaryName" -uninstall
Start-Sleep -Seconds 2
}
& "$InstallDir\$BinaryName" -config $ConfigFile -install
if ($LASTEXITCODE -ne 0) {
Write-Error "Dienst-Installation fehlgeschlagen"
exit 1
}
# Dienst starten
Write-Host "Starte Dienst..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 2
$svc = Get-Service -Name $ServiceName
Write-Host "Dienst-Status: $($svc.Status)"
if ($svc.Status -eq "Running") {
Write-Host "`nInstallation erfolgreich!" -ForegroundColor Green
Write-Host "Log: $InstallDir\rmm-agent.log"
} else {
Write-Warning "Dienst laeuft nicht — bitte Log pruefen: $InstallDir\rmm-agent.log"
}

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

@ -0,0 +1,380 @@
//go:build windows
package main
import (
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"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.2.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, cfg, agentID)
for {
select {
case <-stop:
log.Println("Agent wird gestoppt")
return
case <-ticker.C:
sendHeartbeat(c, cfg, agentID)
}
}
}
func sendHeartbeat(c *client.Client, cfg *config.Config, 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 && resp.UpdateVersion != "" && resp.UpdateVersion != Version {
log.Printf("Update verfuegbar: v%s → v%s", Version, resp.UpdateVersion)
go doSelfUpdate(cfg, resp.UpdateVersion, resp.UpdateHash)
}
}
// doSelfUpdate laedt das neue Binary herunter und startet einen
// PowerShell-Script der nach Dienst-Stop das Binary tauscht und neu startet.
func doSelfUpdate(cfg *config.Config, newVersion, expectedHash string) {
log.Printf("OTA-Update: Starte Download v%s", newVersion)
exe, err := os.Executable()
if err != nil {
log.Printf("OTA-Update: Executable-Pfad: %v", err)
return
}
dir := filepath.Dir(exe)
tmpBin := filepath.Join(dir, "rmm-agent-windows-update.exe")
scriptPath := filepath.Join(dir, "rmm-update.ps1")
// Binary herunterladen
dlURL := fmt.Sprintf("%s/api/v1/firmware/download?platform=windows&api_key=%s",
cfg.BackendURL, cfg.APIKey)
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure}}
hc := &http.Client{Transport: tr, Timeout: 10 * time.Minute}
req, err := http.NewRequest("GET", dlURL, nil)
if err != nil {
log.Printf("OTA-Update: Request: %v", err)
return
}
req.Header.Set("X-API-Key", cfg.APIKey)
resp, err := hc.Do(req)
if err != nil {
log.Printf("OTA-Update: Download: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("OTA-Update: HTTP %d", resp.StatusCode)
return
}
f, err := os.Create(tmpBin)
if err != nil {
log.Printf("OTA-Update: Temp-Datei: %v", err)
return
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
f.Close()
os.Remove(tmpBin)
log.Printf("OTA-Update: Schreiben: %v", err)
return
}
f.Close()
// Hash pruefen
gotHash := hex.EncodeToString(h.Sum(nil))
if expectedHash != "" && gotHash != expectedHash {
os.Remove(tmpBin)
log.Printf("OTA-Update: Hash-Mismatch: erwartet=%s, erhalten=%s", expectedHash, gotHash)
return
}
log.Printf("OTA-Update: Download OK (hash=%s), starte Update-Script", gotHash[:12])
// PowerShell-Script schreiben das nach Dienst-Stop das Binary tauscht
psScript := fmt.Sprintf(`
Start-Sleep -Seconds 3
Stop-Service -Name RMMAgent -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Copy-Item -Path "%s" -Destination "%s" -Force
Remove-Item -Path "%s" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "%s" -Force -ErrorAction SilentlyContinue
Start-Service -Name RMMAgent
`, tmpBin, exe, tmpBin, scriptPath)
if err := os.WriteFile(scriptPath, []byte(psScript), 0644); err != nil {
log.Printf("OTA-Update: Script schreiben: %v", err)
return
}
// Script detached starten (laeuft weiter wenn dieser Prozess beendet wird)
cmd := exec.Command("powershell", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden", "-File", scriptPath)
if err := cmd.Start(); err != nil {
log.Printf("OTA-Update: Script starten: %v", err)
return
}
// cmd.Wait() nicht aufrufen — Script laeuft unabhaengig weiter
log.Printf("OTA-Update: Script gestartet (PID %d), Dienst wird gestoppt", cmd.Process.Pid)
}
// loadOrRegister liest die Agent-ID aus Datei oder registriert sich neu
func loadOrRegister(c *client.Client, cfg *config.Config, cfgPath string) (string, error) {
idFile := filepath.Join(filepath.Dir(cfgPath), AgentIDFile)
if data, err := os.ReadFile(idFile); err == nil {
id := string(data)
if len(id) > 0 {
// Re-registrieren um sicherzustellen dass der Agent im Backend bekannt ist
hostname, _ := os.Hostname()
c.Register(client.RegisterRequest{
AgentID: id,
Name: cfg.AgentName,
Hostname: hostname,
IP: "",
AgentVersion: Version,
Platform: "windows",
})
return id, nil
}
}
// Neu registrieren
hostname, _ := os.Hostname()
name := cfg.AgentName
if name == "" {
name = hostname
}
id, err := c.Register(client.RegisterRequest{
Name: name,
Hostname: hostname,
AgentVersion: Version,
Platform: "windows",
})
if err != nil {
return "", fmt.Errorf("Registrierung: %w", err)
}
if err := os.WriteFile(idFile, []byte(id), 0644); err != nil {
log.Printf("Agent-ID speichern fehlgeschlagen: %v", err)
}
log.Printf("Neu registriert als: %s", id)
return id, nil
}
// defaultConfigPath gibt den Standardpfad zur Konfigurationsdatei zurück
func defaultConfigPath() string {
exe, err := os.Executable()
if err != nil {
return `C:\Program Files\RMMAgent\config.yaml`
}
return filepath.Join(filepath.Dir(exe), "config.yaml")
}
// installService installiert den Agent als Windows-Dienst
func installService(cfg *config.Config, cfgPath string) {
m, err := mgr.Connect()
if err != nil {
log.Fatalf("Service Manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(ServiceName)
if err == nil {
s.Close()
log.Fatalf("Dienst '%s' existiert bereits — zuerst deinstallieren", ServiceName)
}
exe, err := os.Executable()
if err != nil {
log.Fatalf("Executable-Pfad: %v", err)
}
s, err = m.CreateService(ServiceName, exe, mgr.Config{
DisplayName: ServiceDisplayName,
Description: ServiceDescription,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "-config", cfgPath)
if err != nil {
log.Fatalf("Dienst erstellen: %v", err)
}
defer s.Close()
// Event Log registrieren
eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info)
log.Printf("Dienst '%s' installiert", ServiceName)
log.Println("Starten mit: net start RMMAgent")
}
// uninstallService entfernt den Windows-Dienst
func uninstallService() {
m, err := mgr.Connect()
if err != nil {
log.Fatalf("Service Manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(ServiceName)
if err != nil {
log.Fatalf("Dienst nicht gefunden: %v", err)
}
defer s.Close()
s.Control(svc.Stop)
time.Sleep(2 * time.Second)
if err := s.Delete(); err != nil {
log.Fatalf("Dienst loeschen: %v", err)
}
eventlog.Remove(ServiceName)
log.Printf("Dienst '%s' deinstalliert", ServiceName)
}

Binary file not shown.

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

@ -0,0 +1,150 @@
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{})
// Ping-Loop: alle 30s einen Ping senden um Verbindung am Leben zu halten
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-done:
return
case <-stop:
return
}
}
}()
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.SetWriteDeadline(time.Now().Add(10 * time.Second))
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)
}

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

@ -0,0 +1,327 @@
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"`
Params map[string]interface{} `json:"params,omitempty"` // Backend -> Agent
Data interface{} `json:"data,omitempty"` // Agent -> Backend (Response)
}
type Handler struct {
wingetPath string
}
func NewHandler() *Handler {
return &Handler{}
}
// winget gibt den Pfad zu winget.exe zurueck (cached)
func (h *Handler) winget() string {
if h.wingetPath != "" {
return h.wingetPath
}
// Direkt im PATH?
if path, err := exec.LookPath("winget"); err == nil {
h.wingetPath = path
return path
}
// WindowsApps durchsuchen
script := `Get-ChildItem "C:\Program Files\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
out, err := cmd.Output()
if err == nil {
path := strings.TrimSpace(string(out))
if path != "" {
h.wingetPath = path
log.Printf("winget gefunden: %s", path)
return path
}
}
log.Printf("winget nicht gefunden")
return ""
}
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.Params
if data == nil {
data, _ = msg.Data.(map[string]interface{})
}
command, _ := data["command"].(string)
if command == "" {
resp.Status = "error"
resp.Error = "Kein Befehl angegeben"
return resp
}
// winget durch vollen Pfad ersetzen falls noetig
if strings.Contains(command, "winget") {
if wp := h.winget(); wp != "" {
command = strings.ReplaceAll(command, "winget ", fmt.Sprintf(`& "%s" `, wp))
}
}
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}
wp := h.winget()
if wp == "" {
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
}
out, err := runWithTimeout(fmt.Sprintf(`& "%s" list --accept-source-agreements 2>&1`, wp), 60)
if err != nil && out == "" {
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.Params
if data == nil { data, _ = msg.Data.(map[string]interface{}) }
pkg, _ := data["package"].(string)
if pkg == "" {
resp.Status = "error"; resp.Error = "Kein Paket angegeben"; return resp
}
wp := h.winget()
if wp == "" {
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
}
cmd := fmt.Sprintf(`& "%s" install --id %s --silent --accept-package-agreements --accept-source-agreements 2>&1`, wp, 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.Params
if data == nil { data, _ = msg.Data.(map[string]interface{}) }
pkg, _ := data["package"].(string)
if pkg == "" {
resp.Status = "error"; resp.Error = "Kein Paket angegeben"; return resp
}
wp := h.winget()
if wp == "" {
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
}
cmd := fmt.Sprintf(`& "%s" upgrade --id %s --silent --accept-package-agreements --accept-source-agreements 2>&1`, wp, 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}
wp := h.winget()
if wp == "" {
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
}
out, err := runWithTimeout(fmt.Sprintf(`& "%s" upgrade --all --silent --accept-package-agreements --accept-source-agreements 2>&1`, wp), 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("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
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()
}

View File

@ -24,28 +24,55 @@ func (h *Handler) listAPIKeys(w http.ResponseWriter, r *http.Request) {
func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) { func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
Permissions string `json:"permissions"` // "agent" | "read" | "write" | "admin"
CustomerID *int `json:"customer_id"` // optional, null = global
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich") writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
return return
} }
// Permissions validieren, default: "read"
validPerms := map[string]bool{"agent": true, "read": true, "write": true, "admin": true}
if req.Permissions == "" {
req.Permissions = "read"
}
if !validPerms[req.Permissions] {
writeError(w, http.StatusBadRequest, "Ungueltige Permissions (agent|read|write|admin)")
return
}
// Zufaelligen 32-Byte Key generieren // Zufaelligen 32-Byte Key generieren
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) rand.Read(b)
key := hex.EncodeToString(b) key := hex.EncodeToString(b)
apiKey, err := h.db.CreateAPIKey(req.Name, key) apiKey, err := h.db.CreateAPIKey(req.Name, key, req.Permissions, req.CustomerID)
if err != nil { if err != nil {
log.Printf("API-Key erstellen fehlgeschlagen: %v", err) log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen") writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
return return
} }
log.Printf("Neuer API-Key erstellt: %s (%s...)", apiKey.Name, key[:8]) log.Printf("Neuer API-Key erstellt: %s (%s...) perm=%s", apiKey.Name, key[:8], apiKey.Permissions)
writeJSON(w, http.StatusCreated, apiKey) writeJSON(w, http.StatusCreated, apiKey)
} }
// GET /api/v1/customers/{id}/apikeys
func (h *Handler) listCustomerAPIKeys(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige ID")
return
}
keys, err := h.db.ListCustomerAPIKeys(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "Laden fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, keys)
}
// DELETE /api/v1/apikeys/{id} // DELETE /api/v1/apikeys/{id}
func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") idStr := r.PathValue("id")

View File

@ -1,11 +1,36 @@
package api package api
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"github.com/cynfo/rmm-backend/db"
) )
// generateCustomerKey erstellt automatisch einen API-Key fuer einen Kunden
func generateCustomerKey(database *db.Database, customerID int, number, name, perm string) (*db.APIKey, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
key := hex.EncodeToString(b)
var label string
switch perm {
case "agent":
label = fmt.Sprintf("Agent — %s %s", number, name)
case "read":
label = fmt.Sprintf("Read-only — %s %s", number, name)
default:
label = fmt.Sprintf("%s — %s %s", perm, number, name)
}
return database.CreateAPIKey(label, key, perm, &customerID)
}
// GET /api/v1/customers // GET /api/v1/customers
func (h *Handler) listCustomers(w http.ResponseWriter, r *http.Request) { func (h *Handler) listCustomers(w http.ResponseWriter, r *http.Request) {
customers, err := h.db.GetCustomers() customers, err := h.db.GetCustomers()
@ -31,8 +56,20 @@ func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen") writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen")
return return
} }
// Automatisch Agent-Key erstellen
agentKey, err := generateCustomerKey(h.db, c.ID, req.Number, req.Name, "agent")
if err != nil {
log.Printf("Auto-Agent-Key fuer Kunde %s fehlgeschlagen: %v", req.Number, err)
}
h.auditLog(r, "customer.create", "customer", req.Number, req.Name, "") h.auditLog(r, "customer.create", "customer", req.Number, req.Name, "")
writeJSON(w, http.StatusCreated, c)
resp := map[string]interface{}{
"customer": c,
"agent_key": agentKey,
}
writeJSON(w, http.StatusCreated, resp)
} }
// GET /api/v1/customers/{id} // GET /api/v1/customers/{id}

View File

@ -184,8 +184,20 @@ func (h *Handler) pushAgentUpdate(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id") agentID := r.PathValue("id")
// TODO: Agent-Plattform aus DB lesen; fuer jetzt default freebsd // Plattform aus Request-Body oder Query-Parameter lesen, default linux
version, hash, binary, err := h.db.GetLatestFirmware("freebsd") platform := r.URL.Query().Get("platform")
if platform == "" {
var reqBody struct {
Platform string `json:"platform"`
}
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &reqBody)
platform = reqBody.Platform
}
if platform == "" {
platform = "linux"
}
version, hash, binary, err := h.db.GetLatestFirmware(platform)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "Keine Firmware verfuegbar") writeError(w, http.StatusNotFound, "Keine Firmware verfuegbar")
return return

View File

@ -59,6 +59,10 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("GET /api/v1/apikeys", h.listAPIKeys) mux.HandleFunc("GET /api/v1/apikeys", h.listAPIKeys)
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey) mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey) mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
mux.HandleFunc("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys)
// WAU-Regeln
h.setupWAURoutes(mux)
// System Settings // System Settings
mux.HandleFunc("GET /api/v1/settings", h.getSettings) mux.HandleFunc("GET /api/v1/settings", h.getSettings)
@ -251,7 +255,12 @@ func (h *Handler) heartbeat(w http.ResponseWriter, r *http.Request) {
h.db.UpdateAgentVersion(req.AgentID, req.AgentVersion) h.db.UpdateAgentVersion(req.AgentID, req.AgentVersion)
} }
if err := h.db.SaveSystemData(req.AgentID, &req.SystemData); err != nil { // Genutzten API-Key speichern (aus Middleware-Kontext)
if keyInfo := GetAPIKeyFromContext(r); keyInfo != nil {
h.db.UpdateAgentAPIKey(req.AgentID, keyInfo.Key)
}
if err := h.db.SaveSystemDataRaw(req.AgentID, req.SystemData); err != nil {
if strings.Contains(err.Error(), "nicht gefunden") { if strings.Contains(err.Error(), "nicht gefunden") {
writeError(w, http.StatusNotFound, "Agent nicht gefunden") writeError(w, http.StatusNotFound, "Agent nicht gefunden")
return return
@ -333,10 +342,15 @@ func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) {
return return
} }
// system_data als Roh-JSON ausgeben (plattformunabhaengig)
var rawSD interface{} = sysData
if sysData != nil && len(sysData.RawJSON) > 0 {
rawSD = json.RawMessage(sysData.RawJSON)
}
result := map[string]interface{}{ result := map[string]interface{}{
"agent": agent, "agent": agent,
"status": models.CalculateAgentStatus(agent.LastHeartbeat), "status": models.CalculateAgentStatus(agent.LastHeartbeat),
"system_data": sysData, "system_data": rawSD,
} }
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }

View File

@ -15,9 +15,9 @@ func (h *Handler) uploadInstaller(w http.ResponseWriter, r *http.Request) {
if platform == "" { if platform == "" {
platform = "freebsd" platform = "freebsd"
} }
validPlatforms := map[string]bool{"freebsd": true, "linux": true} validPlatforms := map[string]bool{"freebsd": true, "linux": true, "windows": true}
if !validPlatforms[platform] { if !validPlatforms[platform] {
writeError(w, http.StatusBadRequest, "Ungueltige Plattform (freebsd, linux)") writeError(w, http.StatusBadRequest, "Ungueltige Plattform (freebsd, linux, windows)")
return return
} }

View File

@ -8,18 +8,21 @@ import (
"sync" "sync"
"time" "time"
"github.com/cynfo/rmm-backend/db" db "github.com/cynfo/rmm-backend/db"
) )
type contextKey string type contextKey string
const UserContextKey contextKey = "user" const UserContextKey contextKey = "user"
// APIKeyContextKey fuer Key-Infos im Request-Context
const APIKeyContextKey contextKey = "apikey"
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh // APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
type APIKeyCache struct { type APIKeyCache struct {
db *db.Database db *db.Database
mu sync.RWMutex mu sync.RWMutex
keys map[string]bool keys map[string]*db.APIKey // key -> APIKey mit Permissions + CustomerID
lastLoad time.Time lastLoad time.Time
ttl time.Duration ttl time.Duration
} }
@ -27,7 +30,7 @@ type APIKeyCache struct {
func NewAPIKeyCache(database *db.Database) *APIKeyCache { func NewAPIKeyCache(database *db.Database) *APIKeyCache {
c := &APIKeyCache{ c := &APIKeyCache{
db: database, db: database,
keys: make(map[string]bool), keys: make(map[string]*db.APIKey),
ttl: 30 * time.Second, ttl: 30 * time.Second,
} }
c.refresh() c.refresh()
@ -35,14 +38,15 @@ func NewAPIKeyCache(database *db.Database) *APIKeyCache {
} }
func (c *APIKeyCache) refresh() { func (c *APIKeyCache) refresh() {
dbKeys, err := c.db.GetAllAPIKeys() dbKeys, err := c.db.ListAPIKeys()
if err != nil { if err != nil {
log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err) log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
return return
} }
newKeys := make(map[string]bool, len(dbKeys)) newKeys := make(map[string]*db.APIKey, len(dbKeys))
for _, k := range dbKeys { for i := range dbKeys {
newKeys[k] = true k := dbKeys[i]
newKeys[k.Key] = &k
} }
c.mu.Lock() c.mu.Lock()
c.keys = newKeys c.keys = newKeys
@ -50,44 +54,62 @@ func (c *APIKeyCache) refresh() {
c.mu.Unlock() c.mu.Unlock()
} }
func (c *APIKeyCache) IsValid(key string) bool { func (c *APIKeyCache) Get(key string) *db.APIKey {
c.mu.RLock() c.mu.RLock()
expired := time.Since(c.lastLoad) > c.ttl expired := time.Since(c.lastLoad) > c.ttl
valid := c.keys[key] info := c.keys[key]
c.mu.RUnlock() c.mu.RUnlock()
if expired { if expired {
c.refresh() c.refresh()
c.mu.RLock() c.mu.RLock()
valid = c.keys[key] info = c.keys[key]
c.mu.RUnlock() c.mu.RUnlock()
} }
if valid { if info != nil {
go c.db.UpdateAPIKeyLastUsed(key) go c.db.UpdateAPIKeyLastUsed(key)
} }
return info
}
return valid // IsValid prueft nur ob Key existiert (Abwaertskompatibilitaet)
func (c *APIKeyCache) IsValid(key string) bool {
return c.Get(key) != nil
} }
// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token // CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
// Setzt APIKey-Info im Context fuer nachgelagerte Permission-Checks.
func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler { func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try API key first // Try API key first (Header oder Query-Parameter)
key := r.Header.Get("X-API-Key") key := r.Header.Get("X-API-Key")
if key == "" { if key == "" {
key = r.URL.Query().Get("api_key") key = r.URL.Query().Get("api_key")
} }
if key != "" && cache.IsValid(key) { if key != "" {
next.ServeHTTP(w, r) if info := cache.Get(key); info != nil {
// Permission-Check
if !hasAPIKeyPermission(info, r) {
http.Error(w, `{"error":"forbidden: unzureichende Berechtigungen"}`, http.StatusForbidden)
return return
} }
ctx := context.WithValue(r.Context(), APIKeyContextKey, info)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
// Try JWT // Try JWT — Authorization Header oder ?token= Query-Parameter (fuer WebSocket)
var jwtToken string
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") { if strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ") jwtToken = strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(token) } else if t := r.URL.Query().Get("token"); t != "" {
jwtToken = t
}
if jwtToken != "" {
claims, err := auth.ValidateToken(jwtToken)
if err == nil { if err == nil {
ctx := context.WithValue(r.Context(), UserContextKey, claims) ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
@ -99,6 +121,66 @@ func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Hand
}) })
} }
// hasAPIKeyPermission prueft ob ein API-Key fuer den aktuellen Request berechtigt ist
func hasAPIKeyPermission(key *db.APIKey, r *http.Request) bool {
perm := key.Permissions
method := r.Method
path := r.URL.Path
switch perm {
case "agent":
// Agent-Endpunkte: Registrierung, WS-Connect, Heartbeat, OTA-Update
return path == "/api/v1/agent/ws" ||
path == "/api/v1/agent/heartbeat" ||
path == "/api/v1/agent/register" ||
path == "/api/v1/firmware" ||
path == "/api/v1/firmware/download" ||
path == "/api/v1/installer/download" ||
strings.HasPrefix(path, "/api/v1/agents/")
case "read":
// Nur GET-Requests, keine destruktiven Aktionen
if method != http.MethodGet {
return false
}
// Customer-Scope pruefen (falls gesetzt)
return checkCustomerScope(key, r)
case "write":
// Alles ausser Terminal (Terminal ist JWT-only)
// Customer-Scope pruefen
return checkCustomerScope(key, r)
case "admin":
// Vollen Zugriff (kein Terminal — das prueft JWTOnlyAuth separat)
return true
default:
return false
}
}
// checkCustomerScope prueft ob ein customer-scoped Key nur auf seinen Kunden zugreift
func checkCustomerScope(key *db.APIKey, r *http.Request) bool {
if key.CustomerID == nil {
return true // Kein Scope = globaler Zugriff
}
// Customer-ID aus dem Pfad extrahieren (z.B. /api/v1/agents/{id} -> Agent-Customer pruefen)
// Fuer jetzt: customer-scoped Keys werden im Handler weiter eingeschraenkt (via GetAPIKeyFromContext)
// Der eigentliche Scope-Check passiert in den Handlern die GetAPIKeyFromContext nutzen
return true
}
// GetAPIKeyFromContext gibt den API-Key aus dem Context zurueck (nil wenn JWT-Auth)
func GetAPIKeyFromContext(r *http.Request) *db.APIKey {
if v := r.Context().Value(APIKeyContextKey); v != nil {
if k, ok := v.(*db.APIKey); ok {
return k
}
}
return nil
}
// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback) // CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler { func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
keySet := make(map[string]bool, len(validKeys)) keySet := make(map[string]bool, len(validKeys))
@ -131,6 +213,38 @@ func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http
}) })
} }
// JWTOnlyAuth - Nur JWT akzeptieren, kein API-Key (z.B. fuer Terminal-Zugriff)
// Erfordert echten Login mit User/Pass (+2FA), kein Maschinentoken reicht.
func JWTOnlyAuth(auth *AuthHandler, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var jwtToken string
// Authorization Header
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
jwtToken = strings.TrimPrefix(authHeader, "Bearer ")
}
// ?token= Query-Parameter (fuer WebSocket, Browser kann keinen Header setzen)
if jwtToken == "" {
jwtToken = r.URL.Query().Get("token")
}
if jwtToken == "" {
http.Error(w, `{"error":"unauthorized: JWT erforderlich"}`, http.StatusUnauthorized)
return
}
claims, err := auth.ValidateToken(jwtToken)
if err != nil {
http.Error(w, `{"error":"unauthorized: ungültiger Token"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// CORS Middleware // CORS Middleware
func CORS(next http.Handler) http.Handler { func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -17,8 +17,8 @@ var termUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
} }
// terminalHandler bridget Browser-WebSocket mit Agent-PTY // TerminalHandler bridget Browser-WebSocket mit Agent-PTY (JWT-only, kein API-Key)
func (h *Handler) terminalHandler(hub *ws.Hub) http.HandlerFunc { func (h *Handler) TerminalHandler(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id") agentID := r.PathValue("id")
if agentID == "" { if agentID == "" {

View File

@ -37,6 +37,7 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub)) mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub))
// Backup-Endpoints // Backup-Endpoints
mux.HandleFunc("POST /api/v1/agents/{id}/proxmox/action", h.proxmoxAction(hub))
mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub)) mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub))
mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups()) mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups())
mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup()) mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup())
@ -44,7 +45,7 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups()) mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups())
// Terminal-Endpoint (Browser -> Agent PTY) // Terminal-Endpoint (Browser -> Agent PTY)
mux.HandleFunc("GET /api/v1/agents/{id}/terminal", h.terminalHandler(hub)) // Terminal wird NICHT hier registriert — wird in main.go unter JWTOnlyAuth eingebunden
// WebSocket-Endpoint // WebSocket-Endpoint
mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub)) mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub))
@ -265,6 +266,36 @@ func (h *Handler) agentReboot(hub *ws.Hub) http.HandlerFunc {
} }
} }
// proxmoxAction: VM/CT starten oder stoppen
func (h *Handler) proxmoxAction(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
var req struct {
Type string `json:"type"` // "vm" oder "ct"
VMID int `json:"vmid"`
Action string `json:"action"` // "start", "stop", "shutdown"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
return
}
if req.VMID <= 0 || req.Type == "" || req.Action == "" {
writeError(w, http.StatusBadRequest, "type, vmid und action erforderlich")
return
}
params := map[string]interface{}{
"type": req.Type,
"vmid": float64(req.VMID),
"action": req.Action,
}
h.auditLog(r, "proxmox_action", "agent", agentID, "", fmt.Sprintf("%s %s %d", req.Action, req.Type, req.VMID))
h.sendCommandAndWait(hub, agentID, "proxmox_action", params, 60, w)
}
}
// sendAgentCommand erstellt einen einfachen Command-Handler ohne Request-Body // sendAgentCommand erstellt einen einfachen Command-Handler ohne Request-Body
func (h *Handler) sendAgentCommand(hub *ws.Hub, command string, params map[string]interface{}, timeoutSec int) http.HandlerFunc { func (h *Handler) sendAgentCommand(hub *ws.Hub, command string, params map[string]interface{}, timeoutSec int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {

203
backend/api/wau.go Normal file
View File

@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
// setupWAURoutes registriert alle WAU-Endpunkte
func (h *Handler) setupWAURoutes(mux *http.ServeMux) {
// Authentifizierte Endpunkte (API-Key oder JWT)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/rules", h.listWAURules)
mux.HandleFunc("POST /api/v1/customers/{id}/wau/rules", h.addWAURule)
mux.HandleFunc("DELETE /api/v1/customers/{id}/wau/rules/{rid}", h.deleteWAURule)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/compliance", h.getWAUCompliance)
// Compliance-Summary für alle Kunden
mux.HandleFunc("GET /api/v1/wau/compliance/summary", h.getWAUComplianceSummary)
// Plaintext-Endpunkte für WAU (kein Auth nötig — enthält nur App-IDs)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/included", h.wauIncludedList)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/excluded", h.wauExcludedList)
}
// GET /api/v1/customers/{id}/wau/rules
func (h *Handler) listWAURules(w http.ResponseWriter, r *http.Request) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
return
}
rules, err := h.db.ListWAURules(customerID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
return
}
writeJSON(w, http.StatusOK, rules)
}
// POST /api/v1/customers/{id}/wau/rules
func (h *Handler) addWAURule(w http.ResponseWriter, r *http.Request) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
return
}
var req struct {
WingetID string `json:"winget_id"`
DisplayName string `json:"display_name"`
RuleType string `json:"rule_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
req.WingetID = strings.TrimSpace(req.WingetID)
req.DisplayName = strings.TrimSpace(req.DisplayName)
if req.WingetID == "" {
writeError(w, http.StatusBadRequest, "winget_id erforderlich")
return
}
if req.RuleType != "allow" && req.RuleType != "block" {
writeError(w, http.StatusBadRequest, "rule_type muss 'allow' oder 'block' sein")
return
}
if req.DisplayName == "" {
req.DisplayName = req.WingetID
}
rule, err := h.db.AddWAURule(customerID, req.WingetID, req.DisplayName, req.RuleType)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern")
return
}
h.auditLog(r, "wau.rule.add", "customer", strconv.Itoa(customerID), "",
req.RuleType+": "+req.WingetID)
writeJSON(w, http.StatusCreated, rule)
}
// DELETE /api/v1/customers/{id}/wau/rules/{rid}
func (h *Handler) deleteWAURule(w http.ResponseWriter, r *http.Request) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
return
}
ruleID, err := strconv.Atoi(r.PathValue("rid"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Regel-ID")
return
}
ok, err := h.db.DeleteWAURule(customerID, ruleID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen")
return
}
if !ok {
writeError(w, http.StatusNotFound, "Regel nicht gefunden")
return
}
h.auditLog(r, "wau.rule.delete", "customer", strconv.Itoa(customerID), "", strconv.Itoa(ruleID))
writeJSON(w, http.StatusOK, map[string]string{"message": "Regel gelöscht"})
}
// GET /api/v1/customers/{id}/wau/inventory
func (h *Handler) getWAUInventory(w http.ResponseWriter, r *http.Request) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
return
}
entries, err := h.db.GetWAUSoftwareInventory(customerID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des Inventars")
return
}
writeJSON(w, http.StatusOK, entries)
}
// GET /api/v1/customers/{id}/wau/included → Plaintext für WAU
func (h *Handler) wauIncludedList(w http.ResponseWriter, r *http.Request) {
h.serveWAUList(w, r, "allow")
}
// GET /api/v1/customers/{id}/wau/excluded → Plaintext für WAU
func (h *Handler) wauExcludedList(w http.ResponseWriter, r *http.Request) {
h.serveWAUList(w, r, "block")
}
func (h *Handler) serveWAUList(w http.ResponseWriter, r *http.Request, ruleType string) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "Ungültige Kunden-ID", http.StatusBadRequest)
return
}
ids, err := h.db.GetWAUList(customerID, ruleType)
if err != nil {
http.Error(w, "Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(strings.Join(ids, "\r\n")))
if len(ids) > 0 {
w.Write([]byte("\r\n"))
}
}
// wauExpectedURLs leitet die erwarteten WAU-URLs aus dem Request-Host ab
func wauExpectedURLs(r *http.Request, customerID int) (includeURL, excludeURL string) {
scheme := "https"
if r.TLS == nil && (r.Header.Get("X-Forwarded-Proto") == "" || r.Header.Get("X-Forwarded-Proto") == "http") {
scheme = "http"
}
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
scheme = fwd
}
host := r.Host
base := scheme + "://" + host
includeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/included"
excludeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/excluded"
return
}
// GET /api/v1/customers/{id}/wau/compliance
func (h *Handler) getWAUCompliance(w http.ResponseWriter, r *http.Request) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
return
}
includeURL, excludeURL := wauExpectedURLs(r, customerID)
agents, err := h.db.GetWAUCompliance(customerID, includeURL, excludeURL)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"agents": agents,
"include_url": includeURL,
"exclude_url": excludeURL,
})
}
// GET /api/v1/wau/compliance/summary — Zusammenfassung für alle Kunden
func (h *Handler) getWAUComplianceSummary(w http.ResponseWriter, r *http.Request) {
// Ohne customer_id können wir keine URLs ableiten — wir geben Summary ohne URL-Check zurück
// oder nutzen customer_id=0 als Wildcard (kein URL-Check)
summaries, err := h.db.GetAllCustomersWAUSummary("", "")
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
return
}
writeJSON(w, http.StatusOK, summaries)
}

View File

@ -140,6 +140,7 @@ func (d *Database) migrate() error {
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS agent_version TEXT NOT NULL DEFAULT ''") d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS agent_version TEXT NOT NULL DEFAULT ''")
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE") d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE")
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'freebsd'") d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'freebsd'")
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS last_api_key TEXT NOT NULL DEFAULT ''")
// Agent-Firmware Tabelle (Multi-Plattform) // Agent-Firmware Tabelle (Multi-Plattform)
d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware ( d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware (
@ -201,6 +202,11 @@ func (d *Database) migrate() error {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ last_used TIMESTAMPTZ
)`) )`)
// Migration: customer_id + permissions Spalten hinzufuegen falls nicht vorhanden
d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL`)
d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS permissions TEXT NOT NULL DEFAULT 'admin'`)
// Bestehende Keys auf 'admin' setzen (Abwaertskompatibilitaet)
d.db.Exec(`UPDATE api_keys SET permissions = 'admin' WHERE permissions = ''`)
// Installer-Pakete (install.sh + Plugin als ZIP pro Plattform) // Installer-Pakete (install.sh + Plugin als ZIP pro Plattform)
d.db.Exec(`CREATE TABLE IF NOT EXISTS installers ( d.db.Exec(`CREATE TABLE IF NOT EXISTS installers (
@ -274,6 +280,18 @@ func (d *Database) migrate() error {
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`) d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
// WAU-Regeln pro Kunde
d.db.Exec(`CREATE TABLE IF NOT EXISTS customer_wau_rules (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
winget_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
rule_type TEXT NOT NULL CHECK (rule_type IN ('allow', 'block')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(customer_id, winget_id)
)`)
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_wau_rules_customer ON customer_wau_rules(customer_id, rule_type)`)
// Retention Policy (90 Tage) // Retention Policy (90 Tage)
d.setupRetention() d.setupRetention()
@ -348,6 +366,52 @@ func (d *Database) RegisterAgent(agent *models.Agent) error {
return err return err
} }
// SaveSystemDataRaw speichert rohe JSON-Systemdaten (plattformunabhaengig)
func (d *Database) SaveSystemDataRaw(agentID string, raw json.RawMessage) error {
now := time.Now().UTC()
// Leeres JSON als leeres Objekt behandeln
if len(raw) == 0 {
raw = json.RawMessage("{}")
}
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE agents SET last_heartbeat = $1 WHERE id = $2", now, agentID)
if err != nil {
return err
}
rows, _ := res.RowsAffected()
if rows == 0 {
return fmt.Errorf("Agent %s nicht gefunden", agentID)
}
_, err = tx.Exec(`
INSERT INTO system_data (agent_id, data_json, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT(agent_id) DO UPDATE SET
data_json=EXCLUDED.data_json,
updated_at=EXCLUDED.updated_at
`, agentID, string(raw), now)
if err != nil {
return err
}
// Metriken nur fuer OPNsense/Linux (haben bekannte Struct-Form)
var sd models.SystemData
if err2 := json.Unmarshal(raw, &sd); err2 == nil && sd.CPU.Threads > 0 {
if err2 := d.writeMetrics(tx, agentID, now, &sd); err2 != nil {
log.Printf("Metriken schreiben Warnung: %v", err2)
}
}
return tx.Commit()
}
func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error { func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error {
now := time.Now().UTC() now := time.Now().UTC()
@ -486,7 +550,7 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data *
} }
func (d *Database) GetAgents() ([]models.Agent, error) { func (d *Database) GetAgents() ([]models.Agent, error) {
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, agent_version, platform, registered_at, last_heartbeat, customer_id, update_requested FROM agents ORDER BY name") rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, agent_version, platform, registered_at, last_heartbeat, customer_id, update_requested, last_api_key FROM agents ORDER BY name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -498,7 +562,7 @@ func (d *Database) GetAgents() ([]models.Agent, error) {
var lastHB sql.NullTime var lastHB sql.NullTime
var custID sql.NullInt64 var custID sql.NullInt64
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.Platform, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested); err != nil { if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.Platform, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested, &a.LastAPIKey); err != nil {
return nil, err return nil, err
} }
if lastHB.Valid { if lastHB.Valid {
@ -582,7 +646,7 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error
a.CustomerID = &cid a.CustomerID = &cid
} }
// Systemdaten laden // Systemdaten laden (raw JSON — plattformunabhaengig)
var dataJSON sql.NullString var dataJSON sql.NullString
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = $1", id).Scan(&dataJSON) err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = $1", id).Scan(&dataJSON)
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
@ -590,11 +654,10 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error
} }
var sysData *models.SystemData var sysData *models.SystemData
if dataJSON.Valid { if dataJSON.Valid && dataJSON.String != "" {
sysData = &models.SystemData{} sysData = &models.SystemData{}
if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil { json.Unmarshal([]byte(dataJSON.String), sysData)
return &a, nil, nil sysData.RawJSON = json.RawMessage(dataJSON.String)
}
} }
return &a, sysData, nil return &a, sysData, nil
@ -622,6 +685,10 @@ func (d *Database) UpdateAgentVersion(id, version string) error {
return err return err
} }
func (d *Database) UpdateAgentAPIKey(id, apiKey string) {
d.db.Exec("UPDATE agents SET last_api_key = $1 WHERE id = $2", apiKey, id)
}
// Update-Request Flag setzen/lesen/loeschen // Update-Request Flag setzen/lesen/loeschen
func (d *Database) SetUpdateRequest(id string, requested bool) error { func (d *Database) SetUpdateRequest(id string, requested bool) error {
_, err := d.db.Exec("UPDATE agents SET update_requested = $1 WHERE id = $2", requested, id) _, err := d.db.Exec("UPDATE agents SET update_requested = $1 WHERE id = $2", requested, id)
@ -1093,19 +1160,19 @@ func (d *Database) UnassignAgentCustomer(agentID string) error {
return err return err
} }
func (d *Database) GetAgentsByCustomer(customerID int) ([]models.Agent, error) { func (d *Database) GetAgentsByCustomer(customerID int) ([]models.AgentResponse, error) {
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents WHERE customer_id = $1 ORDER BY name", customerID) rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id, agent_version, last_api_key FROM agents WHERE customer_id = $1 ORDER BY name", customerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var agents []models.Agent var agents []models.AgentResponse
for rows.Next() { for rows.Next() {
var a models.Agent var a models.Agent
var lastHB sql.NullTime var lastHB sql.NullTime
var custID sql.NullInt64 var custID sql.NullInt64
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID); err != nil { if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID, &a.AgentVersion, &a.LastAPIKey); err != nil {
return nil, err return nil, err
} }
if lastHB.Valid { if lastHB.Valid {
@ -1115,10 +1182,13 @@ func (d *Database) GetAgentsByCustomer(customerID int) ([]models.Agent, error) {
cid := int(custID.Int64) cid := int(custID.Int64)
a.CustomerID = &cid a.CustomerID = &cid
} }
agents = append(agents, a) agents = append(agents, models.AgentResponse{
Agent: a,
Status: models.CalculateAgentStatus(a.LastHeartbeat),
})
} }
if agents == nil { if agents == nil {
agents = []models.Agent{} agents = []models.AgentResponse{}
} }
return agents, rows.Err() return agents, rows.Err()
} }
@ -1135,16 +1205,23 @@ func (d *Database) GetAgentStatus(agentID string) string {
// ==================== API-Keys ==================== // ==================== API-Keys ====================
// Permissions: "agent" | "read" | "write" | "admin"
// - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat (fuer Agents auf Kundenservern)
// - read: GET-Endpoints, optional auf customer_id beschraenkt
// - write: alles ausser Terminal (fuer externe Integrationen)
// - admin: voll (kein Terminal — das ist JWT-only)
type APIKey struct { type APIKey struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Key string `json:"key"` Key string `json:"key"`
Permissions string `json:"permissions"`
CustomerID *int `json:"customer_id,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsed *time.Time `json:"last_used,omitempty"` LastUsed *time.Time `json:"last_used,omitempty"`
} }
func (d *Database) ListAPIKeys() ([]APIKey, error) { func (d *Database) ListAPIKeys() ([]APIKey, error) {
rows, err := d.db.Query("SELECT id, name, key, created_at, last_used FROM api_keys ORDER BY created_at") rows, err := d.db.Query("SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys ORDER BY created_at")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1154,12 +1231,17 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) {
for rows.Next() { for rows.Next() {
var k APIKey var k APIKey
var lastUsed sql.NullTime var lastUsed sql.NullTime
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt, &lastUsed); err != nil { var custID sql.NullInt64
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed); err != nil {
return nil, err return nil, err
} }
if lastUsed.Valid { if lastUsed.Valid {
k.LastUsed = &lastUsed.Time k.LastUsed = &lastUsed.Time
} }
if custID.Valid {
id := int(custID.Int64)
k.CustomerID = &id
}
keys = append(keys, k) keys = append(keys, k)
} }
if keys == nil { if keys == nil {
@ -1168,15 +1250,24 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) {
return keys, rows.Err() return keys, rows.Err()
} }
func (d *Database) CreateAPIKey(name, key string) (*APIKey, error) { func (d *Database) CreateAPIKey(name, key, permissions string, customerID *int) (*APIKey, error) {
var k APIKey var k APIKey
var custID sql.NullInt64
if customerID != nil {
custID = sql.NullInt64{Int64: int64(*customerID), Valid: true}
}
var scannedCustID sql.NullInt64
err := d.db.QueryRow( err := d.db.QueryRow(
"INSERT INTO api_keys (name, key) VALUES ($1, $2) RETURNING id, name, key, created_at", "INSERT INTO api_keys (name, key, permissions, customer_id) VALUES ($1, $2, $3, $4) RETURNING id, name, key, permissions, customer_id, created_at",
name, key, name, key, permissions, custID,
).Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt) ).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &scannedCustID, &k.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if scannedCustID.Valid {
id := int(scannedCustID.Int64)
k.CustomerID = &id
}
return &k, nil return &k, nil
} }
@ -1192,6 +1283,25 @@ func (d *Database) DeleteAPIKey(id int) error {
return nil return nil
} }
// GetAPIKeyInfo gibt Key + Permissions + CustomerID zurueck (fuer Middleware)
func (d *Database) GetAPIKeyInfo(key string) (*APIKey, error) {
var k APIKey
var custID sql.NullInt64
var lastUsed sql.NullTime
err := d.db.QueryRow(
"SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE key = $1",
key,
).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed)
if err != nil {
return nil, err
}
if custID.Valid {
id := int(custID.Int64)
k.CustomerID = &id
}
return &k, nil
}
func (d *Database) GetAllAPIKeys() ([]string, error) { func (d *Database) GetAllAPIKeys() ([]string, error) {
rows, err := d.db.Query("SELECT key FROM api_keys") rows, err := d.db.Query("SELECT key FROM api_keys")
if err != nil { if err != nil {
@ -1214,6 +1324,38 @@ func (d *Database) UpdateAPIKeyLastUsed(key string) {
d.db.Exec("UPDATE api_keys SET last_used = NOW() WHERE key = $1", key) d.db.Exec("UPDATE api_keys SET last_used = NOW() WHERE key = $1", key)
} }
func (d *Database) ListCustomerAPIKeys(customerID int) ([]APIKey, error) {
rows, err := d.db.Query(
"SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE customer_id = $1 ORDER BY permissions, created_at",
customerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []APIKey
for rows.Next() {
var k APIKey
var lastUsed sql.NullTime
var custID sql.NullInt64
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed); err != nil {
return nil, err
}
if lastUsed.Valid {
k.LastUsed = &lastUsed.Time
}
if custID.Valid {
id := int(custID.Int64)
k.CustomerID = &id
}
keys = append(keys, k)
}
if keys == nil {
keys = []APIKey{}
}
return keys, rows.Err()
}
// MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig) // MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig)
// --- Audit Log --- // --- Audit Log ---

350
backend/db/wau.go Normal file
View File

@ -0,0 +1,350 @@
package db
import (
"database/sql"
"encoding/json"
"strings"
"github.com/cynfo/rmm-backend/models"
)
// ListWAURules gibt alle WAU-Regeln eines Kunden zurück
func (d *Database) ListWAURules(customerID int) ([]models.WAURule, error) {
rows, err := d.db.Query(`
SELECT id, customer_id, winget_id, display_name, rule_type, created_at
FROM customer_wau_rules
WHERE customer_id = $1
ORDER BY rule_type, lower(display_name)
`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var rules []models.WAURule
for rows.Next() {
var r models.WAURule
if err := rows.Scan(&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt); err != nil {
return nil, err
}
rules = append(rules, r)
}
if rules == nil {
rules = []models.WAURule{}
}
return rules, rows.Err()
}
// AddWAURule fügt eine neue Regel hinzu (upsert auf winget_id)
func (d *Database) AddWAURule(customerID int, wingetID, displayName, ruleType string) (*models.WAURule, error) {
var r models.WAURule
err := d.db.QueryRow(`
INSERT INTO customer_wau_rules (customer_id, winget_id, display_name, rule_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (customer_id, winget_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
rule_type = EXCLUDED.rule_type
RETURNING id, customer_id, winget_id, display_name, rule_type, created_at
`, customerID, wingetID, displayName, ruleType).Scan(
&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt,
)
if err != nil {
return nil, err
}
return &r, nil
}
// DeleteWAURule löscht eine Regel (per ID, gehört zu customerID)
func (d *Database) DeleteWAURule(customerID, ruleID int) (bool, error) {
res, err := d.db.Exec(`
DELETE FROM customer_wau_rules WHERE id = $1 AND customer_id = $2
`, ruleID, customerID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// GetWAUList gibt alle winget-IDs eines bestimmten Typs zurück (für den Plaintext-Endpoint)
func (d *Database) GetWAUList(customerID int, ruleType string) ([]string, error) {
rows, err := d.db.Query(`
SELECT winget_id FROM customer_wau_rules
WHERE customer_id = $1 AND rule_type = $2
ORDER BY lower(winget_id)
`, customerID, ruleType)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// GetWAUSoftwareInventory aggregiert installierte Software aller Windows-Agents eines Kunden
func (d *Database) GetWAUSoftwareInventory(customerID int) ([]models.WAUSoftwareEntry, error) {
rows, err := d.db.Query(`
SELECT
app->>'name' AS name,
COALESCE(app->>'publisher', '') AS publisher,
COUNT(DISTINCT sd.agent_id) AS device_count,
jsonb_agg(DISTINCT app->>'version') FILTER (WHERE (app->>'version') != '') AS versions
FROM agents a
JOIN system_data sd ON sd.agent_id = a.id
CROSS JOIN jsonb_array_elements(sd.data_json->'windows'->'installed_software') AS app
WHERE a.customer_id = $1
AND sd.data_json ? 'windows'
AND jsonb_typeof(sd.data_json->'windows'->'installed_software') = 'array'
AND (app->>'name') IS NOT NULL
AND (app->>'name') != ''
GROUP BY app->>'name', app->>'publisher'
ORDER BY lower(app->>'name')
`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []models.WAUSoftwareEntry
for rows.Next() {
var e models.WAUSoftwareEntry
var versionsJSON sql.NullString
if err := rows.Scan(&e.Name, &e.Publisher, &e.DeviceCount, &versionsJSON); err != nil {
return nil, err
}
if versionsJSON.Valid && versionsJSON.String != "" && versionsJSON.String != "null" {
json.Unmarshal([]byte(versionsJSON.String), &e.Versions)
}
if e.Versions == nil {
e.Versions = []string{}
}
entries = append(entries, e)
}
if entries == nil {
entries = []models.WAUSoftwareEntry{}
}
return entries, rows.Err()
}
// GetWAUCompliance berechnet den Compliance-Status aller Windows-Agents eines Kunden
// Prüft: WAU installiert, URLs korrekt, alle allow-Pakete installiert
func (d *Database) GetWAUCompliance(customerID int, expectedIncludeURL, expectedExcludeURL string) ([]models.WAUAgentCompliance, error) {
// Alle Windows-Agents des Kunden mit ihren system_data holen
rows, err := d.db.Query(`
SELECT a.id, a.hostname, sd.data_json
FROM agents a
JOIN system_data sd ON sd.agent_id = a.id
WHERE a.customer_id = $1
AND sd.data_json ? 'windows'
ORDER BY lower(a.hostname)
`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
// Allow-Pakete (= Required) für diesen Kunden laden
allowRules, err := d.GetWAUList(customerID, "allow")
if err != nil {
return nil, err
}
allowRulesFull, err := d.ListWAURules(customerID)
if err != nil {
return nil, err
}
// Map winget_id → display_name
displayNames := make(map[string]string)
for _, r := range allowRulesFull {
if r.RuleType == "allow" {
displayNames[r.WingetID] = r.DisplayName
}
}
var result []models.WAUAgentCompliance
for rows.Next() {
var agentID, hostname string
var dataJSON []byte
if err := rows.Scan(&agentID, &hostname, &dataJSON); err != nil {
continue
}
// JSON parsen (nur was wir brauchen)
var data struct {
Windows struct {
InstalledSoftware []struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"installed_software"`
WAU *struct {
Installed bool `json:"installed"`
IncludeURL string `json:"include_url"`
ExcludeURL string `json:"exclude_url"`
PendingCount int `json:"pending_updates"`
} `json:"wau"`
} `json:"windows"`
}
if err := json.Unmarshal(dataJSON, &data); err != nil {
continue
}
ac := models.WAUAgentCompliance{
AgentID: agentID,
AgentName: hostname,
}
// WAU-Status
if data.Windows.WAU != nil {
wau := data.Windows.WAU
ac.WAUInstalled = wau.Installed
ac.IncludeURL = wau.IncludeURL
ac.ExcludeURL = wau.ExcludeURL
ac.PendingCount = wau.PendingCount
// URL-Check: beide URLs müssen gesetzt sein und passen
includeOK := expectedIncludeURL == "" || wau.IncludeURL == expectedIncludeURL
excludeOK := expectedExcludeURL == "" || wau.ExcludeURL == expectedExcludeURL
ac.WAUConfigOK = wau.Installed && includeOK && excludeOK
}
// Installierte Software als lowercase-Set für schnelles Lookup
installedSet := make(map[string]bool)
for _, s := range data.Windows.InstalledSoftware {
installedSet[strings.ToLower(s.Name)] = true
}
// Pro allow-Paket prüfen ob installiert
for _, wingetID := range allowRules {
displayName := displayNames[wingetID]
if displayName == "" {
displayName = wingetID
}
pkg := models.WAUPackageCompliance{
WingetID: wingetID,
DisplayName: displayName,
}
// Fuzzy-Match: display_name (lowercase) muss in installierter Software enthalten sein
lowerDisplay := strings.ToLower(displayName)
for installedName := range installedSet {
if strings.Contains(installedName, lowerDisplay) ||
strings.Contains(lowerDisplay, installedName) {
pkg.Installed = true
break
}
}
// Winget-ID-Teile als Fallback (z.B. "Mozilla.Firefox" → "firefox")
if !pkg.Installed {
parts := strings.Split(strings.ToLower(wingetID), ".")
if len(parts) > 1 {
appName := parts[len(parts)-1]
for installedName := range installedSet {
if strings.Contains(installedName, appName) {
pkg.Installed = true
break
}
}
}
}
ac.Packages = append(ac.Packages, pkg)
}
// Gesamt-Compliance: WAU installiert + korrekt + alle Pakete da + keine Updates ausstehend
allInstalled := true
for _, p := range ac.Packages {
if !p.Installed {
allInstalled = false
break
}
}
ac.Compliant = ac.WAUInstalled && ac.WAUConfigOK && allInstalled && ac.PendingCount == 0
result = append(result, ac)
}
if result == nil {
result = []models.WAUAgentCompliance{}
}
return result, rows.Err()
}
// GetWAUComplianceSummary gibt eine kompakte Zusammenfassung für die Kundenliste zurück
func (d *Database) GetWAUComplianceSummary(customerID int, expectedIncludeURL, expectedExcludeURL string) (*models.WAUComplianceSummary, error) {
agents, err := d.GetWAUCompliance(customerID, expectedIncludeURL, expectedExcludeURL)
if err != nil {
return nil, err
}
summary := &models.WAUComplianceSummary{
CustomerID: customerID,
TotalAgents: len(agents),
}
for _, a := range agents {
if a.Compliant {
summary.CompliantAgents++
}
if !a.WAUInstalled {
summary.WAUMissing++
} else if !a.WAUConfigOK {
summary.WAUMisconfigured++
}
if a.PendingCount > 0 {
summary.UpdatesPending++
}
for _, p := range a.Packages {
if !p.Installed {
summary.PackagesMissing++
break // pro Agent nur einmal zählen
}
}
}
// Status ableiten
if summary.WAUMissing > 0 || summary.PackagesMissing > 0 {
summary.Status = "critical"
} else if summary.WAUMisconfigured > 0 || summary.UpdatesPending > 0 {
summary.Status = "warning"
} else if summary.TotalAgents == 0 {
summary.Status = "unknown"
} else {
summary.Status = "ok"
}
return summary, nil
}
// GetAllCustomersWAUSummary gibt Compliance-Summary für alle Kunden zurück
func (d *Database) GetAllCustomersWAUSummary(includeURL, excludeURL string) ([]models.WAUComplianceSummary, error) {
rows, err := d.db.Query(`SELECT id FROM customers ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []models.WAUComplianceSummary
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
continue
}
s, err := d.GetWAUComplianceSummary(id, includeURL, excludeURL)
if err != nil {
continue
}
if s.TotalAgents > 0 {
summaries = append(summaries, *s)
}
}
if summaries == nil {
summaries = []models.WAUComplianceSummary{}
}
return summaries, rows.Err()
}

View File

@ -93,6 +93,12 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("POST /api/v1/auth/login", authMux) mux.Handle("POST /api/v1/auth/login", authMux)
mux.Handle("POST /api/v1/auth/2fa/validate", authMux) // 2FA-Schritt 2 ebenfalls ohne JWT mux.Handle("POST /api/v1/auth/2fa/validate", authMux) // 2FA-Schritt 2 ebenfalls ohne JWT
// Terminal: JWT-only (kein API-Key reicht — erfordert echten Login mit 2FA)
terminalMux := http.NewServeMux()
terminalMux.HandleFunc("GET /api/v1/agents/{id}/terminal", handler.TerminalHandler(hub))
mux.Handle("GET /api/v1/agents/{id}/terminal", api.JWTOnlyAuth(authHandler, terminalMux))
mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux)) mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux))
// Middleware-Chain: CORS -> Logging -> Handler // Middleware-Chain: CORS -> Logging -> Handler

View File

@ -15,6 +15,7 @@ type Agent struct {
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
CustomerID *int `json:"customer_id,omitempty"` CustomerID *int `json:"customer_id,omitempty"`
UpdateRequested bool `json:"update_requested"` UpdateRequested bool `json:"update_requested"`
LastAPIKey string `json:"last_api_key,omitempty"`
} }
// AgentResponse erweitert Agent um den berechneten Status // AgentResponse erweitert Agent um den berechneten Status

View File

@ -1,5 +1,7 @@
package models package models
import "encoding/json"
// SystemData - Alle Systemdaten die der Agent sendet // SystemData - Alle Systemdaten die der Agent sendet
type SystemData struct { type SystemData struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
@ -29,6 +31,10 @@ type SystemData struct {
PFStates *PFStatesInfo `json:"pf_states,omitempty"` PFStates *PFStatesInfo `json:"pf_states,omitempty"`
HAProxy *HAProxyData `json:"haproxy,omitempty"` HAProxy *HAProxyData `json:"haproxy,omitempty"`
Caddy *CaddyData `json:"caddy,omitempty"` Caddy *CaddyData `json:"caddy,omitempty"`
// Plattform-spezifische Rohdaten (Windows, Linux, etc.)
Windows json.RawMessage `json:"windows,omitempty"`
// RawJSON: vollstaendige Rohdaten aus DB (wird im API-Response verwendet)
RawJSON json.RawMessage `json:"-"`
} }
type LicenseInfo struct { type LicenseInfo struct {
@ -294,7 +300,7 @@ type HeartbeatRequest struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
AgentVersion string `json:"agent_version,omitempty"` AgentVersion string `json:"agent_version,omitempty"`
Platform string `json:"platform,omitempty"` Platform string `json:"platform,omitempty"`
SystemData SystemData `json:"system_data"` SystemData json.RawMessage `json:"system_data"`
} }
// HeartbeatResponse // HeartbeatResponse

52
backend/models/wau.go Normal file
View File

@ -0,0 +1,52 @@
package models
import "time"
type WAURule struct {
ID int `json:"id"`
CustomerID int `json:"customer_id"`
WingetID string `json:"winget_id"`
DisplayName string `json:"display_name"`
RuleType string `json:"rule_type"` // "allow" | "block"
CreatedAt time.Time `json:"created_at"`
}
type WAUSoftwareEntry struct {
Name string `json:"name"`
Publisher string `json:"publisher"`
DeviceCount int `json:"device_count"`
Versions []string `json:"versions"`
}
// WAUPackageCompliance beschreibt den Compliance-Status eines einzelnen Pakets auf einem Gerät
type WAUPackageCompliance struct {
WingetID string `json:"winget_id"`
DisplayName string `json:"display_name"`
Installed bool `json:"installed"`
HasUpdates bool `json:"has_updates"` // true wenn PendingCount > 0 und dieses Paket betroffen
}
// WAUAgentCompliance beschreibt den vollständigen WAU-Compliance-Status eines Agents
type WAUAgentCompliance struct {
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
WAUInstalled bool `json:"wau_installed"`
WAUConfigOK bool `json:"wau_config_ok"` // Include/Exclude-URLs korrekt gesetzt
IncludeURL string `json:"include_url"` // tatsächlich konfigurierte URL
ExcludeURL string `json:"exclude_url"` // tatsächlich konfigurierte URL
PendingCount int `json:"pending_updates"`
Packages []WAUPackageCompliance `json:"packages"`
Compliant bool `json:"compliant"` // true wenn alles OK
}
// WAUComplianceSummary fasst den Compliance-Status eines Kunden zusammen (für die Kundenliste)
type WAUComplianceSummary struct {
CustomerID int `json:"customer_id"`
TotalAgents int `json:"total_agents"`
CompliantAgents int `json:"compliant_agents"`
WAUMissing int `json:"wau_missing"` // Agents ohne WAU
WAUMisconfigured int `json:"wau_misconfigured"` // WAU installiert, aber falsche URLs
PackagesMissing int `json:"packages_missing"` // Agents mit fehlenden Pflichtpaketen
UpdatesPending int `json:"updates_pending"` // Agents mit ausstehenden Updates
Status string `json:"status"` // "ok" | "warning" | "critical"
}

View File

@ -8,14 +8,28 @@ Einrichtung von PostgreSQL mit TimescaleDB auf dem RMM-Backend-Server (Debian 13
## 1. PostgreSQL installieren ## 1. PostgreSQL installieren
> **Wichtig für Debian 13 (Trixie):** Das Standard-Debian-Repository liefert PostgreSQL 17.8,
> TimescaleDB benötigt aber mindestens 17.9. Deshalb muss zuerst das **offizielle
> PostgreSQL-Apt-Repository** eingerichtet werden — das liefert immer die aktuelle Version.
```bash ```bash
apt update apt update
apt install -y postgresql postgresql-contrib postgresql-common apt install -y curl gnupg
# Offizielles PostgreSQL-Repository einrichten
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
echo "deb https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt update
apt install -y postgresql-17
systemctl enable postgresql systemctl enable postgresql
systemctl start postgresql systemctl start postgresql
# Version prüfen (Trixie liefert PG17) # Version prüfen — muss 17.9 oder neuer zeigen
psql --version psql --version
``` ```
@ -24,17 +38,15 @@ psql --version
## 2. TimescaleDB installieren ## 2. TimescaleDB installieren
```bash ```bash
# Repository einrichten # TimescaleDB-Repository einrichten
apt install -y curl gnupg
echo "deb https://packagecloud.io/timescale/timescaledb/debian/ bookworm main" \ echo "deb https://packagecloud.io/timescale/timescaledb/debian/ bookworm main" \
> /etc/apt/sources.list.d/timescaledb.list > /etc/apt/sources.list.d/timescaledb.list
curl -sL https://packagecloud.io/timescale/timescaledb/gpgkey | \ curl -sL https://packagecloud.io/timescale/timescaledb/gpgkey \
gpg --dearmor > /etc/apt/trusted.gpg.d/timescaledb.gpg | gpg --dearmor > /etc/apt/trusted.gpg.d/timescaledb.gpg
apt update apt update
# Passende Version zu PG17 installieren # TimescaleDB für PostgreSQL 17 installieren
apt install -y timescaledb-2-loader-postgresql-17=2.25.1~debian12-1708 \ apt install -y timescaledb-2-postgresql-17
timescaledb-2-postgresql-17=2.25.1~debian12-1708
# In postgresql.conf aktivieren # In postgresql.conf aktivieren
sed -i "s/#shared_preload_libraries = ''/shared_preload_libraries = 'timescaledb'/" \ sed -i "s/#shared_preload_libraries = ''/shared_preload_libraries = 'timescaledb'/" \
@ -43,6 +55,14 @@ sed -i "s/#shared_preload_libraries = ''/shared_preload_libraries = 'timescaledb
systemctl restart postgresql systemctl restart postgresql
``` ```
> **Falls der Install mit einem Abhängigkeitsfehler fehlschlägt** (`postgresql-17 >= 17.9 required`):
> Das bedeutet, dass noch das Debian-eigene PostgreSQL 17.8 installiert ist.
> Lösung:
> ```bash
> apt remove -y postgresql* && apt autoremove -y
> # Dann Schritt 1 (PostgreSQL aus dem pgdg-Repo) wiederholen
> ```
--- ---
## 3. Datenbank und User anlegen ## 3. Datenbank und User anlegen

6
frontend/.env.example Normal file
View File

@ -0,0 +1,6 @@
# RMM Frontend Konfiguration
# Datei kopieren: cp .env.example .env (wird nicht eingecheckt)
VITE_BACKEND_HOST=your-backend.example.com
VITE_BACKEND_PORT=8443
VITE_API_KEY=your-api-key-here

View File

@ -5,6 +5,7 @@ import AppLayout from './layouts/AppLayout'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Agents from './pages/Agents' import Agents from './pages/Agents'
import Windows from './pages/Windows'
import AgentDetail from './pages/AgentDetail' import AgentDetail from './pages/AgentDetail'
import ProxmoxServers from './pages/ProxmoxServers' import ProxmoxServers from './pages/ProxmoxServers'
import Customers from './pages/Customers' import Customers from './pages/Customers'
@ -53,6 +54,7 @@ export default function App() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="agents" element={<Agents />} /> <Route path="agents" element={<Agents />} />
<Route path="agents/:id" element={<AgentDetail />} /> <Route path="agents/:id" element={<AgentDetail />} />
<Route path="windows" element={<Windows />} />
<Route path="proxmox" element={<ProxmoxServers />} /> <Route path="proxmox" element={<ProxmoxServers />} />
<Route path="tunnels" element={<Tunnels />} /> <Route path="tunnels" element={<Tunnels />} />
<Route path="customers" element={<Customers />} /> <Route path="customers" element={<Customers />} />

View File

@ -224,6 +224,10 @@ class ApiClient {
return this.post(`/api/v1/agents/${agentId}/exec`, { command, timeout }) return this.post(`/api/v1/agents/${agentId}/exec`, { command, timeout })
} }
proxmoxAction(agentId, type, vmid, action) {
return this.post(`/api/v1/agents/${agentId}/proxmox/action`, { type, vmid, action })
}
// Users // Users
getUsers() { getUsers() {
return this.get('/api/v1/users') return this.get('/api/v1/users')
@ -246,12 +250,45 @@ class ApiClient {
return this.get('/api/v1/apikeys') return this.get('/api/v1/apikeys')
} }
createAPIKey(name) { getCustomerAPIKeys(customerId) {
return this.post('/api/v1/apikeys', { name }) return this.get(`/api/v1/customers/${customerId}/apikeys`)
}
// WAU
getWAURules(customerId) {
return this.get(`/api/v1/customers/${customerId}/wau/rules`)
}
addWAURule(customerId, wingetId, displayName, ruleType) {
return this.post(`/api/v1/customers/${customerId}/wau/rules`, {
winget_id: wingetId,
display_name: displayName,
rule_type: ruleType,
})
}
deleteWAURule(customerId, ruleId) {
return this.del(`/api/v1/customers/${customerId}/wau/rules/${ruleId}`)
}
getWAUInventory(customerId) {
return this.get(`/api/v1/customers/${customerId}/wau/inventory`)
}
getWAUCompliance(customerId) {
return this.get(`/api/v1/customers/${customerId}/wau/compliance`)
}
getWAUComplianceSummary() {
return this.get('/api/v1/wau/compliance/summary')
}
createAPIKey(name, permissions = 'read', customerId = null) {
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
} }
deleteAPIKey(id) { deleteAPIKey(id) {
return this.delete(`/api/v1/apikeys/${id}`) return this.del(`/api/v1/apikeys/${id}`)
} }
// System Settings // System Settings

View File

@ -9,34 +9,54 @@ import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor, X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check, Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, FileCode, MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, FileCode,
LayoutGrid, Archive, GitBranch, Wrench, RefreshCw, CalendarClock, Bot,
} from 'lucide-react' } from 'lucide-react'
const buildTabs = (hasPBS) => [ const mainCategories = [
{ id: 'overview', label: 'Uebersicht' }, { id: 'uebersicht', label: 'Uebersicht' },
{ id: 'vms', label: 'Virtuelle Maschinen' }, { id: 'virtualisierung', label: 'Virtualisierung' },
{ id: 'containers', label: 'Container' }, { id: 'speicher', label: 'Speicher' },
{ id: 'storage', label: 'Storage' }, { id: 'system', label: 'System' },
{ id: 'zfs', label: 'ZFS' },
...(hasPBS ? [{ id: 'pbs', label: 'Backup Server' }] : []),
{ id: 'tunnel', label: 'Tunnel' },
{ id: 'services', label: 'Dienste' },
{ id: 'updates', label: 'Updates' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'backups', label: 'Backups' },
{ id: 'agent', label: 'Agent' },
] ]
const buildSubTabs = (hasPBS) => ({
virtualisierung: [
{ id: 'vms', label: 'VMs', icon: Monitor },
{ id: 'containers', label: 'Container', icon: Container },
],
speicher: [
{ id: 'storage', label: 'Storage', icon: HardDrive },
{ id: 'zfs', label: 'ZFS', icon: Database },
{ id: 'backups', label: 'Backups', icon: Archive },
...(hasPBS ? [{ id: 'pbs', label: 'Backup Server', icon: Server }] : []),
],
system: [
{ id: 'services', label: 'Dienste', icon: Settings },
{ id: 'updates', label: 'Updates', icon: RefreshCw },
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock },
{ id: 'tunnel', label: 'Tunnel', icon: Cable },
{ id: 'agent', label: 'Agent', icon: Bot },
],
})
export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) { export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) {
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [tab, setTab] = useState('overview') const [mainTab, setMainTab] = useState('uebersicht')
const [subTab, setSubTab] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
setTab('overview') setMainTab('uebersicht')
setSubTab(null)
api.getAgent(agentId).then(setData).finally(() => setLoading(false)) api.getAgent(agentId).then(setData).finally(() => setLoading(false))
}, [agentId]) }, [agentId])
const handleMainTab = (id, subTabs) => {
setMainTab(id)
setSubTab(subTabs?.[id]?.[0]?.id ?? null)
}
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
const iv = setInterval(() => { const iv = setInterval(() => {
@ -52,12 +72,32 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
const proxmox = sys?.proxmox const proxmox = sys?.proxmox
const pbs = sys?.pbs const pbs = sys?.pbs
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
const tabs = buildTabs(pbs?.available) const subs = buildSubTabs(pbs?.available)
const currentSubs = mainTab !== 'uebersicht' ? (subs[mainTab] || []) : []
const activeSubTab = currentSubs.find(s => s.id === subTab)?.id ?? currentSubs[0]?.id
if (!proxmox?.available) { if (!proxmox?.available) {
return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel> return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel>
} }
const renderContent = () => {
if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
if (mainTab === 'uebersicht') return <OverviewTab sys={sys} proxmox={proxmox} />
const t = activeSubTab
if (t === 'vms') return <VmsTab proxmox={proxmox} agentId={agentId} />
if (t === 'containers') return <ContainersTab proxmox={proxmox} agentId={agentId} />
if (t === 'storage') return <StorageTab proxmox={proxmox} />
if (t === 'zfs') return <ZfsTab proxmox={proxmox} />
if (t === 'backups') return <BackupsTab sys={sys} />
if (t === 'pbs') return <PBSTab pbs={pbs} agentId={agentId} sys={sys} />
if (t === 'services') return <ServicesTab sys={sys} />
if (t === 'updates') return <UpdatesTab sys={sys} />
if (t === 'tasks') return <LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
if (t === 'agent') return <AgentTab agent={data?.agent} status={data?.status} />
return null
}
return ( return (
<Panel onClose={onClose}> <Panel onClose={onClose}>
{/* Header */} {/* Header */}
@ -92,53 +132,60 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
</div> </div>
</div> </div>
{/* Tabs */} {/* Haupt-Tabs */}
<div className="flex gap-1 mt-4 overflow-x-auto text-xs"> <div className="flex gap-1 mt-4 overflow-x-auto text-xs">
{tabs.map((t) => ( {mainCategories.map((cat) => (
<button <button
key={t.id} key={cat.id}
onClick={() => setTab(t.id)} onClick={() => handleMainTab(cat.id, subs)}
className={`px-3 py-1.5 rounded-t whitespace-nowrap transition-colors ${ className={`px-4 py-1.5 whitespace-nowrap transition-colors border-b-2 ${
tab === t.id mainTab === cat.id
? 'bg-gray-900 text-orange-400 border-t border-x border-gray-700' ? 'text-orange-400 border-orange-400'
: 'text-gray-400 hover:text-gray-200' : 'text-gray-400 hover:text-gray-200 border-transparent'
}`} }`}
> >
{t.label} {cat.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Content */} {/* Content + optionale Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Sub-Navigation links — nur wenn nicht Uebersicht */}
{mainTab !== 'uebersicht' && currentSubs.length > 0 && (
<div className="w-44 shrink-0 bg-gray-900/50 border-r border-gray-800 overflow-y-auto py-3">
<div className="px-4 mb-2">
<span className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
{mainCategories.find(c => c.id === mainTab)?.label}
</span>
</div>
{currentSubs.map((item) => {
const Icon = item.icon
const isActive = activeSubTab === item.id
return (
<button
key={item.id}
onClick={() => setSubTab(item.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors ${
isActive
? 'text-orange-400 bg-orange-400/5 border-l-2 border-orange-400'
: 'text-gray-400 hover:text-white border-l-2 border-transparent'
}`}
>
{Icon && <Icon className="w-4 h-4 shrink-0" />}
<span>{item.label}</span>
</button>
)
})}
</div>
)}
{/* Hauptinhalt */}
<div className="flex-1 overflow-y-auto p-6 space-y-4"> <div className="flex-1 overflow-y-auto p-6 space-y-4">
{!sys ? ( {renderContent()}
<div className="text-gray-500">Keine Systemdaten</div> </div>
) : tab === 'overview' ? (
<OverviewTab sys={sys} proxmox={proxmox} />
) : tab === 'vms' ? (
<VmsTab proxmox={proxmox} />
) : tab === 'containers' ? (
<ContainersTab proxmox={proxmox} />
) : tab === 'storage' ? (
<StorageTab proxmox={proxmox} />
) : tab === 'zfs' ? (
<ZfsTab proxmox={proxmox} />
) : tab === 'tunnel' ? (
<TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
) : tab === 'services' ? (
<ServicesTab sys={sys} />
) : tab === 'updates' ? (
<UpdatesTab sys={sys} />
) : tab === 'tasks' ? (
<LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
) : tab === 'pbs' ? (
<PBSTab pbs={pbs} agentId={agentId} sys={sys} />
) : tab === 'backups' ? (
<BackupsTab sys={sys} />
) : tab === 'agent' ? (
<AgentTab agent={data?.agent} status={data?.status} />
) : null}
</div> </div>
</Panel> </Panel>
) )
@ -617,16 +664,76 @@ function OverviewTab({ sys, proxmox }) {
) )
} }
function VmsTab({ proxmox }) { function VmActionButtons({ status, loading, onStart, onStop, onShutdown }) {
const vms = proxmox.vms || [] const isRunning = status === 'running'
const isStopped = status === 'stopped'
if (loading) {
return <span className="text-xs text-gray-500 animate-pulse">...</span>
}
return (
<div className="flex items-center gap-1">
{isStopped && (
<button
onClick={onStart}
title="Starten"
className="p-1 rounded text-green-400 hover:bg-green-900/40 hover:text-green-300 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.344-5.891a1.5 1.5 0 000-2.538L6.3 2.84z" clipRule="evenodd" />
</svg>
</button>
)}
{isRunning && onShutdown && (
<button
onClick={onShutdown}
title="Graceful Shutdown"
className="p-1 rounded text-yellow-400 hover:bg-yellow-900/40 hover:text-yellow-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
</button>
)}
{isRunning && (
<button
onClick={onStop}
title="Hard Stop"
className="p-1 rounded text-red-400 hover:bg-red-900/40 hover:text-red-300 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4h12v12H4V4z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
)
}
function VmsTab({ proxmox, agentId }) {
const vms = proxmox.vms || []
const [pending, setPending] = useState({}) // vmid -> true wenn Action läuft
const [errors, setErrors] = useState({})
// Sort: running first, then by name
const sortedVms = [...vms].sort((a, b) => { const sortedVms = [...vms].sort((a, b) => {
if (a.status === 'running' && b.status !== 'running') return -1 if (a.status === 'running' && b.status !== 'running') return -1
if (a.status !== 'running' && b.status === 'running') return 1 if (a.status !== 'running' && b.status === 'running') return 1
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
const doAction = async (vmid, action) => {
setPending(p => ({ ...p, [vmid]: true }))
setErrors(e => ({ ...e, [vmid]: null }))
try {
await api.proxmoxAction(agentId, 'vm', vmid, action)
} catch (err) {
setErrors(e => ({ ...e, [vmid]: err.message }))
} finally {
setPending(p => ({ ...p, [vmid]: false }))
}
}
return ( return (
<> <>
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3> <h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
@ -644,6 +751,7 @@ function VmsTab({ proxmox }) {
<th className="px-3 py-2">RAM</th> <th className="px-3 py-2">RAM</th>
<th className="px-3 py-2">Disk</th> <th className="px-3 py-2">Disk</th>
<th className="px-3 py-2">Uptime</th> <th className="px-3 py-2">Uptime</th>
<th className="px-3 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700/50"> <tbody className="divide-y divide-gray-700/50">
@ -653,6 +761,7 @@ function VmsTab({ proxmox }) {
<td className="px-3 py-2 text-white font-medium">{vm.name}</td> <td className="px-3 py-2 text-white font-medium">{vm.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<StatusBadge status={vm.status} /> <StatusBadge status={vm.status} />
{errors[vm.vmid] && <div className="text-red-400 text-xs mt-1">{errors[vm.vmid]}</div>}
</td> </td>
<td className="px-3 py-2 text-gray-400"> <td className="px-3 py-2 text-gray-400">
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'} {vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
@ -668,6 +777,15 @@ function VmsTab({ proxmox }) {
<td className="px-3 py-2 text-gray-400"> <td className="px-3 py-2 text-gray-400">
{vm.uptime ? formatUptime(vm.uptime) : '—'} {vm.uptime ? formatUptime(vm.uptime) : '—'}
</td> </td>
<td className="px-3 py-2">
<VmActionButtons
status={vm.status}
loading={!!pending[vm.vmid]}
onStart={() => doAction(vm.vmid, 'start')}
onStop={() => doAction(vm.vmid, 'stop')}
onShutdown={() => doAction(vm.vmid, 'shutdown')}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -678,16 +796,29 @@ function VmsTab({ proxmox }) {
) )
} }
function ContainersTab({ proxmox }) { function ContainersTab({ proxmox, agentId }) {
const containers = proxmox.containers || [] const containers = proxmox.containers || []
const [pending, setPending] = useState({})
const [errors, setErrors] = useState({})
// Sort: running first, then by name
const sortedCt = [...containers].sort((a, b) => { const sortedCt = [...containers].sort((a, b) => {
if (a.status === 'running' && b.status !== 'running') return -1 if (a.status === 'running' && b.status !== 'running') return -1
if (a.status !== 'running' && b.status === 'running') return 1 if (a.status !== 'running' && b.status === 'running') return 1
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
const doAction = async (vmid, action) => {
setPending(p => ({ ...p, [vmid]: true }))
setErrors(e => ({ ...e, [vmid]: null }))
try {
await api.proxmoxAction(agentId, 'ct', vmid, action)
} catch (err) {
setErrors(e => ({ ...e, [vmid]: err.message }))
} finally {
setPending(p => ({ ...p, [vmid]: false }))
}
}
return ( return (
<> <>
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3> <h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
@ -704,6 +835,7 @@ function ContainersTab({ proxmox }) {
<th className="px-3 py-2">CPU</th> <th className="px-3 py-2">CPU</th>
<th className="px-3 py-2">RAM</th> <th className="px-3 py-2">RAM</th>
<th className="px-3 py-2">Uptime</th> <th className="px-3 py-2">Uptime</th>
<th className="px-3 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700/50"> <tbody className="divide-y divide-gray-700/50">
@ -713,6 +845,7 @@ function ContainersTab({ proxmox }) {
<td className="px-3 py-2 text-white font-medium">{ct.name}</td> <td className="px-3 py-2 text-white font-medium">{ct.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<StatusBadge status={ct.status} /> <StatusBadge status={ct.status} />
{errors[ct.vmid] && <div className="text-red-400 text-xs mt-1">{errors[ct.vmid]}</div>}
</td> </td>
<td className="px-3 py-2 text-gray-400"> <td className="px-3 py-2 text-gray-400">
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'} {ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
@ -724,6 +857,14 @@ function ContainersTab({ proxmox }) {
<td className="px-3 py-2 text-gray-400"> <td className="px-3 py-2 text-gray-400">
{ct.uptime ? formatUptime(ct.uptime) : '—'} {ct.uptime ? formatUptime(ct.uptime) : '—'}
</td> </td>
<td className="px-3 py-2">
<VmActionButtons
status={ct.status}
loading={!!pending[ct.vmid]}
onStart={() => doAction(ct.vmid, 'start')}
onStop={() => doAction(ct.vmid, 'stop')}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { X, Play, Loader, Terminal, Copy, Check } from 'lucide-react' import { X, Play, Loader, Terminal, Copy, Check } from 'lucide-react'
import api from '../api/client' import api from '../api/client'
import { copyToClipboard } from '../utils/clipboard'
export default function ScriptModal({ agentId, agentName, onClose }) { export default function ScriptModal({ agentId, agentName, onClose }) {
const [script, setScript] = useState('#!/bin/bash\n\n') const [script, setScript] = useState('#!/bin/bash\n\n')
@ -35,7 +36,7 @@ export default function ScriptModal({ agentId, agentName, onClose }) {
const copyOutput = () => { const copyOutput = () => {
if (output) { if (output) {
navigator.clipboard.writeText(output) copyToClipboard(output)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }

View File

@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { API_KEY } from '../config' import { API_KEY } from '../config'
import api from '../api/client'
export default function TerminalModal({ agentId, agentName, onClose }) { export default function TerminalModal({ agentId, agentName, onClose }) {
const termRef = useRef(null) const termRef = useRef(null)
@ -13,6 +14,8 @@ export default function TerminalModal({ agentId, agentName, onClose }) {
const [status, setStatus] = useState('connecting') // connecting | open | error | closed const [status, setStatus] = useState('connecting') // connecting | open | error | closed
const backendHost = useSettingsStore.getState().getBackendHost() const backendHost = useSettingsStore.getState().getBackendHost()
// JWT-Token bevorzugen (Browser ist per Login authentifiziert), API-Key als Fallback
const jwtToken = api.getToken()
const apiKey = API_KEY const apiKey = API_KEY
useEffect(() => { useEffect(() => {
@ -58,7 +61,13 @@ export default function TerminalModal({ agentId, agentName, onClose }) {
const cols = term.cols || 220 const cols = term.cols || 220
const rows = term.rows || 50 const rows = term.rows || 50
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${window.location.host}/api/v1/agents/${agentId}/terminal?cols=${cols}&rows=${rows}&api_key=${apiKey}` // JWT bevorzugen, API-Key als Fallback (API-Key nur wenn konfiguriert)
const authParam = jwtToken
? `token=${encodeURIComponent(jwtToken)}`
: apiKey
? `api_key=${encodeURIComponent(apiKey)}`
: ''
const wsUrl = `${wsProtocol}//${window.location.host}/api/v1/agents/${agentId}/terminal?cols=${cols}&rows=${rows}&${authParam}`
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer' ws.binaryType = 'arraybuffer'

View File

@ -0,0 +1,903 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { useSettingsStore } from '../stores/settings'
import StatusBadge from './StatusBadge'
import TerminalModal from './TerminalModal'
import ScriptModal from './ScriptModal'
import {
X, Cpu, MemoryStick, HardDrive, Monitor, Server, Settings,
Download, Trash2, RefreshCw, Package, Search, Play,
ShieldCheck, Bot, Cable, CalendarClock, Zap, CheckCircle2, AlertCircle, Clock,
} from 'lucide-react'
// Navigation
const mainCategories = [
{ id: 'uebersicht', label: 'Uebersicht' },
{ id: 'software', label: 'Software' },
{ id: 'system', label: 'System' },
]
const subTabs = {
software: [
{ id: 'installed', label: 'Installiert', icon: Package },
{ id: 'winget', label: 'winget Updates', icon: RefreshCw },
{ id: 'wupdates', label: 'Windows Updates', icon: ShieldCheck },
{ id: 'wau', label: 'WAU', icon: Zap },
],
system: [
{ id: 'services', label: 'Dienste', icon: Settings },
{ id: 'tunnel', label: 'Tunnel', icon: Cable },
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock },
{ id: 'agent', label: 'Agent', icon: Bot },
],
}
// Panel Shell
function Panel({ onClose, children }) {
return (
<div className="fixed inset-0 z-50 flex items-stretch justify-end bg-black/50">
<div className="w-full max-w-5xl bg-gray-900 flex flex-col shadow-2xl border-l border-gray-700 overflow-hidden">
{children}
</div>
</div>
)
}
// CustomerAssign
function CustomerAssign({ agent, customers, current, onReload }) {
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const handleChange = async (e) => {
const val = e.target.value
setSaving(true)
try {
if (val === '') await api.unassignCustomer(agent.id)
else await api.assignCustomer(agent.id, parseInt(val))
if (onReload) onReload()
} catch (err) {
console.error(err)
} finally {
setSaving(false)
setEditing(false)
}
}
if (editing) return (
<div className="flex items-center gap-2 mt-0.5">
<select
value={agent.customer_id || ''}
onChange={handleChange}
disabled={saving}
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-sky-500"
autoFocus
onBlur={() => !saving && setEditing(false)}
>
<option value=""> Nicht zugewiesen </option>
{customers.sort((a, b) => a.number.localeCompare(b.number)).map(c => (
<option key={c.id} value={c.id}>{c.number} {c.name}</option>
))}
</select>
{saving && <span className="text-xs text-gray-500">Speichere...</span>}
</div>
)
return (
<div
className="text-sm mt-0.5 cursor-pointer hover:underline"
onClick={() => setEditing(true)}
title="Kunde aendern"
>
{current
? <span className="text-sky-400">{current.number} {current.name}</span>
: <span className="text-gray-500 italic">Kein Kunde zugewiesen (klicken zum Zuweisen)</span>
}
</div>
)
}
// Main Component
export default function WindowsPanel({ agentId, customers, onClose, onReload }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [mainTab, setMainTab] = useState('uebersicht')
const [subTab, setSubTab] = useState(null)
useEffect(() => {
setLoading(true)
setMainTab('uebersicht')
setSubTab(null)
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
}, [agentId])
useEffect(() => {
if (!data) return
const iv = setInterval(() => api.getAgent(agentId).then(setData), 30000)
return () => clearInterval(iv)
}, [agentId, data])
if (loading) return <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
const { agent, system_data: sys, status } = data
const win = sys?.windows
const cust = agent.customer_id ? customers.find(c => c.id === agent.customer_id) : null
const currentSubs = mainTab !== 'uebersicht' ? (subTabs[mainTab] || []) : []
const activeSubTab = currentSubs.find(s => s.id === subTab)?.id ?? currentSubs[0]?.id
const handleMainTab = (id) => {
setMainTab(id)
setSubTab(subTabs[id]?.[0]?.id ?? null)
}
const renderContent = () => {
if (mainTab === 'uebersicht') return <OverviewTab win={win} agent={agent} status={status} />
const t = activeSubTab
if (t === 'installed') return <InstalledSoftwareTab win={win} />
if (t === 'winget') return <WingetUpgradeTab agentId={agentId} />
if (t === 'wupdates') return <WindowsUpdatesTab agentId={agentId} />
if (t === 'wau') return <WAUTab agentId={agentId} customerId={agent.customer_id} />
if (t === 'services') return <ServicesTab win={win} />
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={agent} />
if (t === 'tasks') return <TasksTab agentId={agentId} agentName={agent.name} />
if (t === 'agent') return <AgentInfoTab agent={agent} status={status} />
return null
}
return (
<Panel onClose={onClose}>
{/* Header */}
<div className="px-6 py-4 bg-gradient-to-r from-gray-800 to-gray-900 border-b border-gray-700">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<Monitor className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-bold text-white">{agent.name}</h2>
<StatusBadge status={status} />
</div>
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
<div className="text-xs text-gray-500 mt-1">
{win?.hostname || agent.hostname}
{win?.os_version && <span> &middot; {win.os_version}</span>}
{win?.domain && <span> &middot; {win.domain}</span>}
<span> &middot; IP: {agent.ip}</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={async () => {
if (!confirm(`Agent "${agent.name}" wirklich loeschen?`)) return
await api.deleteAgent(agent.id)
if (onReload) onReload()
onClose()
}}
className="text-gray-500 hover:text-red-400 transition-colors"
title="Agent loeschen"
>
<Trash2 className="w-4 h-4" />
</button>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Haupt-Tabs */}
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
{mainCategories.map(cat => (
<button key={cat.id} onClick={() => handleMainTab(cat.id)}
className={`px-4 py-1.5 whitespace-nowrap transition-colors border-b-2 ${
mainTab === cat.id
? 'text-blue-400 border-blue-400'
: 'text-gray-400 hover:text-gray-200 border-transparent'
}`}>
{cat.label}
</button>
))}
</div>
</div>
{/* Content + Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Sub-Navigation */}
{mainTab !== 'uebersicht' && currentSubs.length > 0 && (
<div className="w-44 shrink-0 bg-gray-900/50 border-r border-gray-800 overflow-y-auto py-3">
<div className="px-4 mb-2">
<span className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
{mainCategories.find(c => c.id === mainTab)?.label}
</span>
</div>
{currentSubs.map(item => {
const Icon = item.icon
const isActive = activeSubTab === item.id
return (
<button key={item.id} onClick={() => setSubTab(item.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors ${
isActive
? 'text-blue-400 bg-blue-400/5 border-l-2 border-blue-400'
: 'text-gray-400 hover:text-white border-l-2 border-transparent'
}`}>
{Icon && <Icon className="w-4 h-4 shrink-0" />}
<span>{item.label}</span>
</button>
)
})}
</div>
)}
{/* Inhalt */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{renderContent()}
</div>
</div>
</Panel>
)
}
// Uebersicht
function OverviewTab({ win, agent, status }) {
const fmt = (b) => {
if (!b) return '—'
if (b >= 1073741824) return `${(b / 1073741824).toFixed(1)} GB`
return `${(b / 1048576).toFixed(0)} MB`
}
const fmtUptime = (s) => {
if (!s) return '—'
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
if (!win) return <div className="text-gray-500">Keine Systemdaten Agent noch nicht verbunden oder erster Heartbeat ausstehend</div>
const memUsed = win.memory?.total_bytes - win.memory?.available_bytes
const memPct = win.memory?.used_percent ?? 0
return (
<div className="space-y-4">
{/* Stats-Karten */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon={Cpu} label="CPU-Last" value={`${win.cpu?.load_percent?.toFixed(1) ?? '—'}%`} sub={win.cpu?.model?.split(' ').slice(-2).join(' ')} color="text-blue-400" pct={win.cpu?.load_percent} />
<StatCard icon={MemoryStick} label="RAM" value={`${memPct.toFixed(0)}%`} sub={`${fmt(memUsed)} / ${fmt(win.memory?.total_bytes)}`} color="text-purple-400" pct={memPct} />
<StatCard icon={Server} label="Uptime" value={fmtUptime(win.uptime_seconds)} sub="seit letztem Start" color="text-green-400" />
<StatCard icon={Monitor} label="OS" value={win.os_version?.replace('Windows ', 'Win ') || '—'} sub={win.os_build ? `Build ${win.os_build}` : ''} color="text-gray-400" />
</div>
{/* CPU-Details */}
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
<div className="text-xs text-gray-500 mb-2 font-semibold uppercase tracking-wide">Prozessor</div>
<div className="text-sm text-white mb-2">{win.cpu?.model || '—'}</div>
<div className="flex gap-6 text-xs text-gray-400">
<span>{win.cpu?.cores ?? '—'} Kerne</span>
<span>{win.cpu?.logical_cores ?? '—'} logische Kerne</span>
</div>
<div className="mt-2">
<UsageBar pct={win.cpu?.load_percent ?? 0} color="bg-blue-500" />
</div>
</div>
{/* Arbeitsspeicher */}
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
<div className="text-xs text-gray-500 mb-2 font-semibold uppercase tracking-wide">Arbeitsspeicher</div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-300">Belegt: {fmt(memUsed)}</span>
<span className="text-gray-500">Gesamt: {fmt(win.memory?.total_bytes)}</span>
</div>
<UsageBar pct={memPct} color={memPct > 85 ? 'bg-red-500' : memPct > 65 ? 'bg-yellow-500' : 'bg-purple-500'} />
</div>
{/* Festplatten */}
{win.disks?.length > 0 && (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
<div className="text-xs text-gray-500 mb-3 font-semibold uppercase tracking-wide">Laufwerke</div>
<div className="space-y-3">
{win.disks.map(d => (
<div key={d.drive}>
<div className="flex justify-between text-sm mb-1">
<span className="text-white font-mono">{d.drive}</span>
<span className="text-gray-400 text-xs">{fmt(d.total_bytes - d.free_bytes)} / {fmt(d.total_bytes)} ({d.used_percent?.toFixed(0)}%)</span>
</div>
<UsageBar pct={d.used_percent} color={d.used_percent > 90 ? 'bg-red-500' : d.used_percent > 75 ? 'bg-yellow-500' : 'bg-green-600'} />
</div>
))}
</div>
</div>
)}
</div>
)
}
// Software / installiert
function InstalledSoftwareTab({ win }) {
const [search, setSearch] = useState('')
const software = win?.installed_software || []
const filtered = search
? software.filter(s =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
(s.publisher || '').toLowerCase().includes(search.toLowerCase())
)
: software
if (software.length === 0) {
return (
<div className="text-gray-500 text-sm">
Keine Software-Daten verfügbar Agent läuft möglicherweise noch auf einer alten Version.
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Name oder Hersteller suchen..."
className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<span className="text-xs text-gray-500 shrink-0">{filtered.length} / {software.length}</span>
</div>
<div className="bg-gray-950 rounded border border-gray-800 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-left text-gray-500">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Version</th>
<th className="px-3 py-2 font-medium">Hersteller</th>
<th className="px-3 py-2 font-medium">Installiert</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-900">
{filtered.map((s, i) => (
<tr key={i} className="hover:bg-gray-900/60">
<td className="px-3 py-1.5 text-white">{s.name}</td>
<td className="px-3 py-1.5 text-gray-400 font-mono">{s.version || '—'}</td>
<td className="px-3 py-1.5 text-gray-500">{s.publisher || '—'}</td>
<td className="px-3 py-1.5 text-gray-600">{formatInstallDate(s.install_date)}</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="text-center py-6 text-gray-500">Keine Treffer</div>
)}
</div>
</div>
)
}
function formatInstallDate(d) {
if (!d || d.length < 8) return '—'
// Format: YYYYMMDD
return `${d.slice(6, 8)}.${d.slice(4, 6)}.${d.slice(0, 4)}`
}
function WingetUpgradeTab({ agentId }) {
const [output, setOutput] = useState(null)
const [running, setRunning] = useState(false)
const [upgradeAll, setUpgradeAll] = useState(false)
const check = async () => {
setRunning(true)
try {
const res = await api.execCommand(agentId, 'winget upgrade --accept-source-agreements', 60)
setOutput(res?.output || res?.data?.output || '')
} catch (e) { setOutput(`Fehler: ${e.message}`) }
finally { setRunning(false) }
}
const doUpgradeAll = async () => {
if (!confirm('Alle verfügbaren winget-Updates installieren?')) return
setUpgradeAll(true)
try {
const res = await api.execCommand(agentId,
'winget upgrade --all --silent --accept-package-agreements --accept-source-agreements', 600)
setOutput(res?.output || res?.data?.output || '')
} catch (e) { setOutput(`Fehler: ${e.message}`) }
finally { setUpgradeAll(false) }
}
useEffect(() => { check() }, [agentId])
return (
<div className="space-y-3">
<div className="flex gap-2">
<button onClick={check} disabled={running}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
<RefreshCw className={`w-3.5 h-3.5 ${running ? 'animate-spin' : ''}`} />
Pruefen
</button>
<button onClick={doUpgradeAll} disabled={running || upgradeAll}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
<Download className="w-3.5 h-3.5" />
{upgradeAll ? 'Installiere...' : 'Alle updaten'}
</button>
</div>
{(running || upgradeAll) && <div className="text-gray-400 text-sm">Bitte warten...</div>}
{output && (
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
{output}
</pre>
)}
</div>
)
}
function WindowsUpdatesTab({ agentId }) {
const [output, setOutput] = useState(null)
const [checking, setChecking] = useState(false)
const [installing, setInstalling] = useState(false)
const script = `$s = New-Object -ComObject Microsoft.Update.Session; $r = $s.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software'"); $r.Updates | ForEach-Object { $kb = if ($_.KBArticleIDs.Count -gt 0) { "KB$($_.KBArticleIDs[0])" } else { "---" }; Write-Output "$kb - $($_.Title)" }; Write-Output ""; Write-Output "Gesamt: $($r.Updates.Count) Updates ausstehend"`
const check = async () => {
setChecking(true)
try {
const res = await api.execCommand(agentId, `powershell -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`, 120)
setOutput(res?.output || res?.data?.output || 'Keine Ausgabe')
} catch (e) { setOutput(`Fehler: ${e.message}`) }
finally { setChecking(false) }
}
const install = async () => {
if (!confirm('Windows Updates installieren? Der PC kann danach neu starten.')) return
setInstalling(true)
setOutput('Updates werden installiert — das kann mehrere Minuten dauern...')
try {
const installScript = `$s=New-Object -ComObject Microsoft.Update.Session; $r=$s.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software'"); if($r.Updates.Count -eq 0){Write-Output "Keine Updates verfuegbar"; exit}; $d=$s.CreateUpdateDownloader(); $d.Updates=$r.Updates; $d.Download(); $i=$s.CreateUpdateInstaller(); $i.Updates=$r.Updates; $ir=$i.Install(); Write-Output "Installiert: $($r.Updates.Count), ResultCode: $($ir.ResultCode)"`
const res = await api.execCommand(agentId, `powershell -NonInteractive -Command "${installScript.replace(/"/g, '\\"')}"`, 1800)
setOutput(res?.output || res?.data?.output || '')
} catch (e) { setOutput(`Fehler: ${e.message}`) }
finally { setInstalling(false) }
}
useEffect(() => { check() }, [agentId])
return (
<div className="space-y-3">
<div className="flex gap-2">
<button onClick={check} disabled={checking || installing}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
<RefreshCw className={`w-3.5 h-3.5 ${checking ? 'animate-spin' : ''}`} />
Pruefen
</button>
<button onClick={install} disabled={checking || installing}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
<ShieldCheck className="w-3.5 h-3.5" />
{installing ? 'Installiere...' : 'Jetzt installieren'}
</button>
</div>
{output && (
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
{output}
</pre>
)}
</div>
)
}
// System-Tabs
function ServicesTab({ win }) {
const [search, setSearch] = useState('')
const services = win?.services || []
const filtered = search
? services.filter(s => s.name?.toLowerCase().includes(search.toLowerCase()) ||
s.display_name?.toLowerCase().includes(search.toLowerCase()))
: services
if (services.length === 0) return <div className="text-gray-500 text-sm">Keine Dienstdaten verfuegbar</div>
return (
<div className="space-y-3">
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Dienste suchen..."
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
<div className="space-y-0.5">
{filtered.map(s => (
<div key={s.name} className="flex items-center gap-2.5 px-2 py-1 rounded hover:bg-gray-800/50">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
s.status === 'running' ? 'bg-green-400' :
s.status === 'stopped' ? 'bg-gray-600' : 'bg-yellow-400'
}`} />
<span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{s.display_name || s.name}</span>
<span className="text-[10px] text-gray-600 flex-shrink-0">{s.start_type}</span>
</div>
))}
</div>
</div>
)
}
function TunnelTab({ agentId, agent }) {
const [showTerminal, setShowTerminal] = useState(false)
const [showScript, setShowScript] = useState(false)
return (
<div className="space-y-4">
<div className="flex gap-3">
<button onClick={() => setShowTerminal(true)}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors">
<Play className="w-4 h-4" />
Terminal
</button>
<button onClick={() => setShowScript(true)}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors">
<Settings className="w-4 h-4" />
Script
</button>
</div>
{showTerminal && <TerminalModal agentId={agentId} agentName={agent?.name || ''} onClose={() => setShowTerminal(false)} />}
{showScript && <ScriptModal agentId={agentId} agentName={agent?.name || ''} onClose={() => setShowScript(false)} />}
</div>
)
}
function TasksTab({ agentId, agentName }) {
const [cmd, setCmd] = useState('')
const [output, setOutput] = useState(null)
const [running, setRunning] = useState(false)
const run = async () => {
if (!cmd.trim()) return
setRunning(true)
try {
const res = await api.execCommand(agentId, cmd, 60)
setOutput(res?.output || res?.data?.output || '')
} catch (e) { setOutput(`Fehler: ${e.message}`) }
finally { setRunning(false) }
}
return (
<div className="space-y-3">
<div className="flex gap-2">
<input value={cmd} onChange={e => setCmd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && run()}
placeholder="Befehl (PowerShell oder cmd)"
className="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white font-mono focus:outline-none focus:border-blue-500" />
<button onClick={run} disabled={running || !cmd.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 rounded text-sm text-white transition-colors">
<Play className="w-3.5 h-3.5" />
{running ? 'Laeuft...' : 'Ausfuehren'}
</button>
</div>
{output !== null && (
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-80 overflow-y-auto">
{output || '(keine Ausgabe)'}
</pre>
)}
</div>
)
}
function AgentInfoTab({ agent, status }) {
return (
<div className="space-y-3">
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-2">
{[
['Agent-ID', agent.id],
['Name', agent.name],
['Hostname', agent.hostname],
['IP', agent.ip],
['Version', agent.agent_version || '—'],
['Plattform', agent.platform],
['Status', status],
['Registriert', new Date(agent.registered_at).toLocaleString('de-DE')],
].map(([k, v]) => (
<div key={k} className="flex gap-4">
<span className="text-xs text-gray-500 w-28 shrink-0">{k}</span>
<span className="text-xs text-gray-300 font-mono break-all">{v}</span>
</div>
))}
</div>
</div>
)
}
// WAU-Tab
function WAUTab({ agentId, customerId }) {
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(false)
const [running, setRunning] = useState(false)
const [log, setLog] = useState(null)
const [installing, setInstalling] = useState(false)
const [externalUrl, setExternalUrl] = useState('')
const [allowRules, setAllowRules] = useState([])
const [enforcing, setEnforcing] = useState(false)
useEffect(() => {
api.getSettings().then(s => {
const url = s?.data?.backend_url_external || s?.backend_url_external
if (url) setExternalUrl(url.replace(/\/$/, ''))
})
if (customerId) {
api.getWAURules(customerId).then(rules => {
setAllowRules((rules || []).filter(r => r.rule_type === 'allow'))
})
}
}, [customerId])
const load = async () => {
setLoading(true)
try {
// Task prüfen (einfachste Methode, kein komplexes Escaping)
const taskRes = await api.execCommand(agentId,
"if (Get-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate' -ErrorAction SilentlyContinue) { 'TASK_FOUND' } else { 'TASK_NOT_FOUND' }",
15)
const taskOut = (taskRes?.output || taskRes?.data?.output || '').trim()
const taskFound = taskOut.includes('TASK_FOUND')
if (!taskFound) {
setStatus({ Installed: false, Version: '', TaskExists: false, TaskState: '', LastRun: '' })
return
}
// Version + letzter Lauf
const infoRes = await api.execCommand(agentId,
"$t = Get-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate' -EA SilentlyContinue; $i = $t | Get-ScheduledTaskInfo; $v = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -EA SilentlyContinue | Where-Object { $_.DisplayName -like '*Winget-AutoUpdate*' } | Select-Object -First 1).DisplayVersion; $lr = if ($i.LastRunTime.Year -gt 2000) { $i.LastRunTime.ToString('yyyy-MM-dd HH:mm') } else { '-' }; Write-Output \"$v|$($t.State)|$lr\"",
15)
const infoOut = (infoRes?.output || infoRes?.data?.output || '').trim()
const [version, taskState, lastRun] = infoOut.split('|')
setStatus({ Installed: true, Version: version || '', TaskExists: true, TaskState: taskState || '', LastRun: lastRun || '' })
} catch (e) {
setStatus(null)
} finally {
setLoading(false)
}
}
const installWAU = async () => {
if (!externalUrl || !customerId) return
setInstalling(true)
setLog('WAU wird heruntergeladen und installiert — bitte warten (1-3 Minuten)...')
const listPath = `${externalUrl}/api/v1/customers/${customerId}/wau/included`
const blockPath = `${externalUrl}/api/v1/customers/${customerId}/wau/excluded`
// MSI direkt herunterladen + synchron mit msiexec installieren
const cmd = `$msi="$env:TEMP\\WAU.msi"; Invoke-WebRequest 'https://github.com/Romanitho/Winget-AutoUpdate/releases/latest/download/WAU.msi' -OutFile $msi -UseBasicParsing; $args=@('/i',$msi,'/qn','NOTIFICATIONLEVEL=2','RUNONSTARTUP=1','UPDATESINTERVAL=Daily','LISTPATH=${listPath}','BLOCKEDAPPSLISTPATH=${blockPath}'); $p=Start-Process msiexec -ArgumentList $args -Wait -PassThru; if(Get-ScheduledTask 'Winget-AutoUpdate' -EA SilentlyContinue){"WAU erfolgreich installiert (ExitCode: $($p.ExitCode))"}else{"Installation fehlgeschlagen (ExitCode: $($p.ExitCode))"}`
try {
const res = await api.execCommand(agentId, cmd, 300)
setLog(res?.output || res?.data?.output || '(keine Ausgabe)')
setTimeout(load, 3000)
} catch (e) {
setLog(`Fehler: ${e.message}`)
} finally {
setInstalling(false)
}
}
const runNow = async () => {
setRunning(true)
setLog(null)
try {
const res = await api.execCommand(agentId,
"Start-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate'; Start-Sleep 2; Write-Output 'WAU gestartet'",
15)
setLog(res?.output || res?.data?.output || 'Gestartet')
setTimeout(load, 5000) // Status nach 5s neu laden
} catch (e) {
setLog(`Fehler: ${e.message}`)
} finally {
setRunning(false)
}
}
const enforceAllowlist = async () => {
if (!allowRules.length) return
setEnforcing(true)
setLog(`Installiere ${allowRules.length} Pakete aus der Allowlist...\n`)
// PowerShell-Loop: jedes Paket einzeln installieren, Ergebnis ausgeben
const ids = allowRules.map(r => r.winget_id).join("','")
const cmd = `$pkgs=@('${ids}'); foreach($p in $pkgs){ Write-Output ">>> $p"; winget install --id $p --silent --accept-package-agreements --accept-source-agreements 2>&1 | Select-Object -Last 3 | Out-String; Write-Output "" }; Write-Output "=== Fertig ==="`
try {
const res = await api.execCommand(agentId, cmd, 600)
setLog(res?.output || res?.data?.output || '(keine Ausgabe)')
} catch (e) {
setLog(`Fehler: ${e.message}`)
} finally {
setEnforcing(false)
}
}
const loadLog = async () => {
const res = await api.execCommand(agentId,
"Get-Content 'C:\\ProgramData\\Winget-AutoUpdate\\Logs\\WAU-updates.log' -Tail 30 -ErrorAction SilentlyContinue",
15)
setLog(res?.output || res?.data?.output || 'Kein Log gefunden')
}
useEffect(() => { load() }, [agentId])
return (
<div className="space-y-4">
{/* Status-Card */}
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-400" />
<span className="text-sm font-medium text-white">Winget-AutoUpdate</span>
</div>
<button onClick={load} disabled={loading} className="text-gray-600 hover:text-gray-400">
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading ? (
<div className="text-gray-500 text-sm">Prüfe Status...</div>
) : status === null ? (
<div className="text-red-400 text-sm">Status konnte nicht abgerufen werden</div>
) : (
<div className="space-y-3">
{/* Installiert / nicht installiert */}
<div className="flex items-center gap-3">
{status.Installed ? (
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
) : (
<AlertCircle className="w-5 h-5 text-gray-600 shrink-0" />
)}
<div>
<div className="text-sm text-white">
{status.Installed ? 'WAU installiert' : 'WAU nicht installiert'}
</div>
{status.Version && (
<div className="text-xs text-gray-500">Version {status.Version}</div>
)}
</div>
</div>
{/* Task-Status + letzter Lauf */}
{status.Installed && (
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="bg-gray-900/60 rounded p-2">
<div className="text-[10px] text-gray-500 mb-1">Geplante Aufgabe</div>
<div className={`text-xs font-medium ${
status.TaskExists ? 'text-green-400' : 'text-red-400'
}`}>
{status.TaskExists ? status.TaskState || 'Vorhanden' : 'Fehlt'}
</div>
</div>
<div className="bg-gray-900/60 rounded p-2">
<div className="text-[10px] text-gray-500 mb-1">Letzter Lauf</div>
<div className="text-xs text-gray-300 flex items-center gap-1">
<Clock className="w-3 h-3 text-gray-600" />
{status.LastRun || '—'}
</div>
</div>
</div>
)}
{/* Aktionen */}
<div className="flex gap-2 pt-1">
{status.Installed ? (
<>
<button
onClick={runNow}
disabled={running}
className="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
>
<Zap className={`w-3.5 h-3.5 ${running ? 'animate-pulse' : ''}`} />
{running ? 'Läuft...' : 'Jetzt ausführen'}
</button>
<button
onClick={loadLog}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors"
>
<Search className="w-3.5 h-3.5" />
Log anzeigen
</button>
</>
) : (
<div className="space-y-2">
{externalUrl && customerId ? (
<button
onClick={installWAU}
disabled={installing}
className="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
>
<Download className={`w-3.5 h-3.5 ${installing ? 'animate-bounce' : ''}`} />
{installing ? 'Installiere...' : 'WAU jetzt installieren'}
</button>
) : (
<div className="text-xs text-gray-500">
{!externalUrl
? 'Externe Backend-URL nicht konfiguriert (Einstellungen)'
: 'Kein Kunde zugeordnet — Agent zuerst einem Kunden zuweisen'}
</div>
)}
</div>
)}
</div>
{/* Kunden-Listen Hinweis */}
{status.Installed && customerId && (
<div className="text-[10px] text-gray-600 border-t border-gray-700/50 pt-2 mt-1">
Allow/Blocklisten werden verwaltet unter: Kunden Kunde auswählen WAU-Tab
</div>
)}
</div>
)}
</div>
{/* Allowlist Desired State */}
{customerId && (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-green-400" />
<span className="text-sm font-medium text-white">Gewünschten Zustand herstellen</span>
</div>
<span className="text-[10px] text-gray-600">{allowRules.length} Pakete in Allowlist</span>
</div>
{allowRules.length === 0 ? (
<div className="text-xs text-gray-600">Keine Allowlist konfiguriert in der Kundenverwaltung unter WAU hinterlegen.</div>
) : (
<>
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
{allowRules.map(r => (
<span key={r.id} className="text-[10px] font-mono px-1.5 py-0.5 bg-gray-900 border border-gray-700 rounded text-gray-400">
{r.winget_id}
</span>
))}
</div>
<div className="flex items-center gap-3">
<button
onClick={enforceAllowlist}
disabled={enforcing}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-700 hover:bg-green-600 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
>
<Download className={`w-3.5 h-3.5 ${enforcing ? 'animate-bounce' : ''}`} />
{enforcing ? 'Installiere...' : 'Fehlende Software installieren'}
</button>
<span className="text-[10px] text-gray-600">Bereits installierte Pakete werden übersprungen</span>
</div>
</>
)}
</div>
)}
{/* Log-Ausgabe */}
{log && (
<div>
<div className="text-xs text-gray-500 mb-1">Log-Ausgabe</div>
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-64 overflow-y-auto">
{log}
</pre>
</div>
)}
</div>
)
}
// Hilfskomponenten
function StatCard({ icon: Icon, label, value, sub, color, pct }) {
return (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${color}`} />
<span className="text-xs text-gray-500">{label}</span>
</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
{sub && <div className="text-xs text-gray-600 mt-0.5 truncate">{sub}</div>}
{pct !== undefined && <UsageBar pct={pct} color={color.replace('text-', 'bg-')} className="mt-2" />}
</div>
)
}
function UsageBar({ pct, color, className = '' }) {
return (
<div className={`w-full bg-gray-700 rounded-full h-1.5 ${className}`}>
<div className={`h-1.5 rounded-full transition-all ${color}`}
style={{ width: `${Math.min(100, pct ?? 0).toFixed(0)}%` }} />
</div>
)
}

View File

@ -1,6 +1,21 @@
// Frontend-Konfiguration — VOR dem Build anpassen, dann als config.js speichern // Frontend-Konfiguration — als config.js speichern vor dem Build
// config.js ist in .gitignore und wird NICHT eingecheckt // config.js ist in .gitignore und wird NICHT eingecheckt
//
// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime-Injection)
// 2. Vite ENV-Variablen (.env / --env beim Build)
// 3. Fallback-Werte hier
//
// Docker-Beispiel (nginx config.js-Injection):
// window.__RMM_CONFIG__ = { backendHost: "192.168.1.10", backendPort: "8443", apiKey: "abc123" }
//
// .env-Beispiel:
// VITE_BACKEND_HOST=192.168.1.10
// VITE_BACKEND_PORT=8443
// VITE_API_KEY=abc123
export const BACKEND_HOST = 'BACKEND_IP_ODER_HOSTNAME' const rt = window.__RMM_CONFIG__ || {}
export const BACKEND_URL = `https://${BACKEND_HOST}:8443`
export const API_KEY = 'DEIN_API_KEY' export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''

8
frontend/src/config.js Normal file
View File

@ -0,0 +1,8 @@
// Frontend-Konfiguration
// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime), 2. Vite ENV (lokaler Build)
const rt = window.__RMM_CONFIG__ || {}
export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''

View File

@ -14,11 +14,13 @@ import {
Download, Download,
HardDrive, HardDrive,
ClipboardList, ClipboardList,
Monitor,
} from 'lucide-react' } from 'lucide-react'
const navItems = [ const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard }, { path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/agents', label: 'Firewalls', icon: Server }, { path: '/agents', label: 'Firewalls', icon: Server },
{ path: '/windows', label: 'Windows', icon: Monitor },
{ path: '/proxmox', label: 'Proxmox', icon: HardDrive }, { path: '/proxmox', label: 'Proxmox', icon: HardDrive },
{ path: '/tunnels', label: 'Tunnel', icon: Cable }, { path: '/tunnels', label: 'Tunnel', icon: Cable },
{ path: '/firmware', label: 'Firmware', icon: Download }, { path: '/firmware', label: 'Firmware', icon: Download },

View File

@ -126,7 +126,7 @@ export default function Agents() {
const cust = a.customer_id ? customerMap[a.customer_id] : null const cust = a.customer_id ? customerMap[a.customer_id] : null
// Nur OPNsense/FreeBSD Agents anzeigen // Nur OPNsense/FreeBSD Agents anzeigen
if (a.platform === 'linux') return null if (a.platform === 'linux' || a.platform === 'windows') return null
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/') const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
const diskPct = rootDisk && rootDisk.total_bytes > 0 const diskPct = rootDisk && rootDisk.total_bytes > 0

View File

@ -1,6 +1,53 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import api from '../api/client' import api from '../api/client'
import { Plus, Trash2, Edit2, X, Check } from 'lucide-react' import { copyToClipboard } from '../utils/clipboard'
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, ShieldAlert, Package, Search, RefreshCw, CircleCheck, CircleX, CircleAlert, Info } from 'lucide-react'
const PERM_LABELS = {
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50' },
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50' },
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
}
// WAU Compliance Badge
function WAUComplianceBadge({ summary }) {
if (!summary) return null
if (summary.total_agents === 0) return null
const { status, compliant_agents, total_agents, wau_missing, wau_misconfigured, packages_missing, updates_pending } = summary
const configs = {
ok: { icon: CircleCheck, cls: 'text-green-400', bg: 'bg-green-900/20 border-green-700/40', label: `WAU OK (${compliant_agents}/${total_agents})` },
warning: { icon: CircleAlert, cls: 'text-yellow-400', bg: 'bg-yellow-900/20 border-yellow-700/40', label: buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) },
critical: { icon: CircleX, cls: 'text-red-400', bg: 'bg-red-900/20 border-red-700/40', label: buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) },
unknown: { icon: Info, cls: 'text-gray-500', bg: 'bg-gray-800/40 border-gray-700/40', label: 'Keine Windows-Agents' },
}
const cfg = configs[status] || configs.unknown
const Icon = cfg.icon
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] font-medium ${cfg.cls} ${cfg.bg}`}>
<Icon className="w-3 h-3" />
{cfg.label}
</span>
)
}
function buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) {
const parts = []
if (wau_misconfigured > 0) parts.push(`${wau_misconfigured} fehlkonfiguriert`)
if (updates_pending > 0) parts.push(`${updates_pending} Updates`)
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
}
function buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) {
const parts = []
if (wau_missing > 0) parts.push(`${wau_missing} ohne WAU`)
if (packages_missing > 0) parts.push(`${packages_missing} Pakete fehlen`)
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
}
export default function Customers() { export default function Customers() {
const [customers, setCustomers] = useState([]) const [customers, setCustomers] = useState([])
@ -8,12 +55,22 @@ export default function Customers() {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [editId, setEditId] = useState(null) const [editId, setEditId] = useState(null)
const [form, setForm] = useState({ number: '', name: '' }) const [form, setForm] = useState({ number: '', name: '' })
const [expandedId, setExpandedId] = useState(null)
const [complianceSummaries, setComplianceSummaries] = useState({}) // customerID summary
const reload = () => { const reload = () => {
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false)) api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
} }
useEffect(() => { reload() }, []) const loadCompliance = () => {
api.getWAUComplianceSummary().then(data => {
const map = {}
;(data || []).forEach(s => { map[s.customer_id] = s })
setComplianceSummaries(map)
}).catch(() => {})
}
useEffect(() => { reload(); loadCompliance() }, [])
const handleAdd = async () => { const handleAdd = async () => {
if (!form.number || !form.name) return if (!form.number || !form.name) return
@ -35,6 +92,10 @@ export default function Customers() {
reload() reload()
} }
const toggleExpand = (id) => {
setExpandedId(prev => prev === id ? null : id)
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -52,8 +113,10 @@ export default function Customers() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-800 text-left text-gray-500"> <tr className="border-b border-gray-800 text-left text-gray-500">
<th className="px-4 py-2 font-medium w-6"></th>
<th className="px-4 py-2 font-medium">Nummer</th> <th className="px-4 py-2 font-medium">Nummer</th>
<th className="px-4 py-2 font-medium">Name</th> <th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium hidden lg:table-cell">WAU</th>
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th> <th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
<th className="px-4 py-2 font-medium w-24"></th> <th className="px-4 py-2 font-medium w-24"></th>
</tr> </tr>
@ -61,6 +124,7 @@ export default function Customers() {
<tbody className="divide-y divide-gray-800"> <tbody className="divide-y divide-gray-800">
{showAdd && ( {showAdd && (
<tr> <tr>
<td></td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<input <input
type="text" type="text"
@ -78,6 +142,7 @@ export default function Customers() {
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500" className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/> />
</td> </td>
<td className="hidden md:table-cell"></td> <td className="hidden md:table-cell"></td>
@ -94,9 +159,10 @@ export default function Customers() {
</tr> </tr>
)} )}
{customers.map((c) => ( {customers.map((c) => (
<tr key={c.id} className="hover:bg-gray-800/50">
{editId === c.id ? (
<> <>
{editId === c.id ? (
<tr key={`edit-${c.id}`} className="bg-gray-800/30">
<td></td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<input <input
type="text" type="text"
@ -124,30 +190,53 @@ export default function Customers() {
</button> </button>
</div> </div>
</td> </td>
</> </tr>
) : ( ) : (
<> <tr
<td className="px-4 py-2 text-orange-400 font-mono">{c.number}</td> key={`row-${c.id}`}
<td className="px-4 py-2 text-white">{c.name}</td> className={`hover:bg-gray-800/50 cursor-pointer transition-colors ${expandedId === c.id ? 'bg-gray-800/30' : ''}`}
<td className="px-4 py-2 text-gray-500 text-xs hidden md:table-cell"> onClick={() => toggleExpand(c.id)}
>
<td className="px-3 py-2.5 text-gray-600">
{expandedId === c.id
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</td>
<td className="px-4 py-2.5 text-orange-400 font-mono">{c.number}</td>
<td className="px-4 py-2.5 text-white">{c.name}</td>
<td className="px-4 py-2.5 hidden lg:table-cell">
<WAUComplianceBadge summary={complianceSummaries[c.id]} />
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
{new Date(c.created_at).toLocaleDateString('de-DE')} {new Date(c.created_at).toLocaleDateString('de-DE')}
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }} onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }}
className="text-gray-500 hover:text-gray-300" className="text-gray-500 hover:text-gray-300 p-1 rounded hover:bg-gray-700"
> >
<Edit2 className="w-4 h-4" /> <Edit2 className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => handleDelete(c.id)} className="text-gray-500 hover:text-red-400"> <button
<Trash2 className="w-4 h-4" /> onClick={() => handleDelete(c.id)}
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-700"
>
<Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</td> </td>
</>
)}
</tr> </tr>
)}
{expandedId === c.id && (
<tr key={`expand-${c.id}`}>
<td colSpan={6} className="px-0 py-0">
<CustomerDetailPanel customerId={c.id} customerNumber={c.number} />
</td>
</tr>
)}
</>
))} ))}
</tbody> </tbody>
</table> </table>
@ -158,3 +247,670 @@ export default function Customers() {
</div> </div>
) )
} }
// CustomerDetailPanel
function CustomerDetailPanel({ customerId, customerNumber }) {
const [activeTab, setActiveTab] = useState('agents')
const tabs = [
{ id: 'agents', label: 'Agents', icon: Monitor },
{ id: 'apikeys', label: 'API-Keys', icon: Key },
{ id: 'wau', label: 'WAU', icon: Package },
]
return (
<div className="bg-gray-800/20 border-t border-gray-800">
{/* Tab-Leiste */}
<div className="flex gap-0 border-b border-gray-800 px-6">
{tabs.map(t => {
const Icon = t.icon
return (
<button
key={t.id}
onClick={(e) => { e.stopPropagation(); setActiveTab(t.id) }}
className={`flex items-center gap-1.5 px-3 py-2 text-xs border-b-2 transition-colors ${
activeTab === t.id
? 'text-orange-400 border-orange-400'
: 'text-gray-500 border-transparent hover:text-gray-300'
}`}
>
<Icon className="w-3 h-3" />
{t.label}
</button>
)
})}
</div>
{/* Inhalt */}
<div onClick={e => e.stopPropagation()}>
{activeTab === 'agents' && <AgentsTab customerId={customerId} />}
{activeTab === 'apikeys' && <APIKeysTab customerId={customerId} />}
{activeTab === 'wau' && <WauTab customerId={customerId} customerNumber={customerNumber} />}
</div>
</div>
)
}
// Agents-Tab
function AgentsTab({ customerId }) {
const [keys, setKeys] = useState(null)
const [agents, setAgents] = useState(null)
useEffect(() => {
api.getCustomerAPIKeys(customerId).then(setKeys)
api.getCustomerAgents(customerId).then(setAgents)
}, [customerId])
if (!keys || !agents) {
return <div className="px-8 py-3 text-gray-500 text-xs">Laden...</div>
}
const agentKey = keys.find(k => k.permissions === 'agent')
return (
<div className="px-8 py-3 space-y-1.5">
<div className="flex items-center gap-2 mb-2">
<Monitor className="w-3.5 h-3.5 text-gray-500" />
<span className="text-xs text-gray-500 font-medium">Agents ({agents.length})</span>
</div>
{agents.length === 0 ? (
<div className="text-xs text-gray-600">Keine Agents zugeordnet</div>
) : (
agents.map((a) => {
const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key
const unknownKey = agentKey && !a.last_api_key
return (
<div key={a.id} className={`flex items-center gap-2.5 rounded px-1.5 py-0.5 -mx-1.5 ${wrongKey ? 'bg-red-900/20' : ''}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
a.status === 'online' ? 'bg-green-400' :
a.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
}`} />
<span className={`text-xs flex-1 min-w-0 truncate ${wrongKey ? 'text-red-300' : 'text-gray-300'}`}>{a.name}</span>
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">{a.agent_version || '—'}</span>
{wrongKey && <span title={`Falscher Key: ${a.last_api_key?.substring(0,8)}...`}><AlertTriangle className="w-3 h-3 text-red-400 flex-shrink-0" /></span>}
{unknownKey && <span title="Noch kein Heartbeat mit Key-Info"><AlertTriangle className="w-3 h-3 text-gray-600 flex-shrink-0" /></span>}
</div>
)
})
)}
</div>
)
}
// API-Keys-Tab
function APIKeysTab({ customerId }) {
const [keys, setKeys] = useState(null)
const [copied, setCopied] = useState(null)
useEffect(() => {
api.getCustomerAPIKeys(customerId).then(setKeys)
}, [customerId])
const copyKey = (key, id) => {
copyToClipboard(key)
setCopied(id)
setTimeout(() => setCopied(null), 2000)
}
if (!keys) return <div className="px-8 py-3 text-gray-500 text-xs">Laden...</div>
return (
<div className="px-8 py-3 space-y-1.5">
<div className="flex items-center gap-2 mb-2">
<Key className="w-3.5 h-3.5 text-gray-500" />
<span className="text-xs text-gray-500 font-medium">API-Keys</span>
</div>
{keys.length === 0 ? (
<div className="text-xs text-gray-600">Keine Keys</div>
) : (
keys.map((k) => {
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
return (
<div key={k.id} className="flex items-center gap-2.5">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
{perm.label}
</span>
<code className="text-xs text-gray-500 font-mono flex-1 min-w-0 truncate">
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}
</code>
<button
onClick={() => copyKey(k.key, k.id)}
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0"
title="Key kopieren"
>
{copied === k.id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
)
})
)}
</div>
)
}
// WAU Compliance Panel
function WAUCompliancePanel({ compliance, loading, onRefresh }) {
const [expanded, setExpanded] = useState(false)
if (loading) return (
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3 text-xs text-gray-500 flex items-center gap-2">
<RefreshCw className="w-3 h-3 animate-spin" /> Compliance wird geprueft...
</div>
)
const agents = compliance?.agents || []
if (agents.length === 0) return null
const compliantCount = agents.filter(a => a.compliant).length
const allOk = compliantCount === agents.length
const overallStatus = agents.some(a => !a.wau_installed || a.packages?.some(p => !p.installed))
? 'critical'
: agents.some(a => !a.wau_config_ok || a.pending_updates > 0)
? 'warning'
: 'ok'
const statusColors = {
ok: 'border-green-700/40 bg-green-900/10',
warning: 'border-yellow-700/40 bg-yellow-900/10',
critical: 'border-red-700/40 bg-red-900/10',
}
const headerColors = {
ok: 'text-green-400',
warning: 'text-yellow-400',
critical: 'text-red-400',
}
const StatusIcon = overallStatus === 'ok' ? CircleCheck : overallStatus === 'warning' ? CircleAlert : CircleX
return (
<div className={`rounded-lg border p-3 ${statusColors[overallStatus]}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusIcon className={`w-4 h-4 ${headerColors[overallStatus]}`} />
<span className={`text-xs font-medium ${headerColors[overallStatus]}`}>
WAU Compliance: {compliantCount}/{agents.length} Agents konform
</span>
</div>
<div className="flex items-center gap-2">
<button onClick={onRefresh} className="text-gray-600 hover:text-gray-400 transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-gray-500 hover:text-gray-300 flex items-center gap-1"
>
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Details
</button>
</div>
</div>
{expanded && (
<div className="mt-3 space-y-2">
{agents.map(agent => (
<AgentComplianceRow key={agent.agent_id} agent={agent} expectedIncludeUrl={compliance?.include_url} />
))}
</div>
)}
</div>
)
}
function AgentComplianceRow({ agent, expectedIncludeUrl }) {
const [open, setOpen] = useState(false)
const missingPkgs = (agent.packages || []).filter(p => !p.installed)
const hasIssues = !agent.wau_installed || !agent.wau_config_ok || missingPkgs.length > 0 || agent.pending_updates > 0
const rowColor = agent.compliant ? 'text-green-400' : hasIssues ? 'text-red-400' : 'text-yellow-400'
const RowIcon = agent.compliant ? CircleCheck : CircleX
return (
<div className="bg-gray-900/60 rounded border border-gray-700/50">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-800/40 transition-colors"
onClick={() => setOpen(v => !v)}
>
<RowIcon className={`w-3.5 h-3.5 flex-shrink-0 ${rowColor}`} />
<span className="text-xs text-white font-mono flex-1">{agent.agent_name}</span>
<div className="flex gap-1 flex-wrap justify-end">
{!agent.wau_installed && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">WAU fehlt</span>
)}
{agent.wau_installed && !agent.wau_config_ok && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">Falsche URLs</span>
)}
{missingPkgs.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">{missingPkgs.length} Paket(e) fehlen</span>
)}
{agent.pending_updates > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">{agent.pending_updates} Updates</span>
)}
{agent.compliant && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/40 border border-green-700/40 text-green-400">OK</span>
)}
</div>
{open ? <ChevronDown className="w-3 h-3 text-gray-600 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-gray-600 flex-shrink-0" />}
</button>
{open && (
<div className="px-4 pb-3 pt-1 space-y-2 border-t border-gray-700/40">
{/* WAU Status */}
<div className="text-[10px] text-gray-400 space-y-1">
<div className="flex items-center gap-1.5">
{agent.wau_installed
? <CircleCheck className="w-3 h-3 text-green-400" />
: <CircleX className="w-3 h-3 text-red-400" />
}
<span>WAU {agent.wau_installed ? `installiert` : 'nicht installiert'}</span>
</div>
{agent.wau_installed && (
<div className="flex items-center gap-1.5">
{agent.wau_config_ok
? <CircleCheck className="w-3 h-3 text-green-400" />
: <CircleX className="w-3 h-3 text-red-400" />
}
<span>
Konfiguration {agent.wau_config_ok ? 'korrekt' : 'falsch'}
{!agent.wau_config_ok && agent.include_url && (
<span className="text-gray-600 ml-1">({agent.include_url || 'keine URL'})</span>
)}
</span>
</div>
)}
{agent.pending_updates > 0 && (
<div className="flex items-center gap-1.5">
<CircleAlert className="w-3 h-3 text-yellow-400" />
<span>{agent.pending_updates} ausstehende Updates</span>
</div>
)}
</div>
{/* Paket-Status */}
{(agent.packages || []).length > 0 && (
<div className="space-y-0.5">
<div className="text-[10px] text-gray-600 font-medium uppercase tracking-wide mb-1">Pflichtpakete</div>
{(agent.packages || []).map(pkg => (
<div key={pkg.winget_id} className="flex items-center gap-1.5 text-[10px]">
{pkg.installed
? <CircleCheck className="w-3 h-3 text-green-400 flex-shrink-0" />
: <CircleX className="w-3 h-3 text-red-400 flex-shrink-0" />
}
<span className={pkg.installed ? 'text-gray-400' : 'text-red-300'}>{pkg.display_name}</span>
<span className="text-gray-700 font-mono">{pkg.winget_id}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
// WAU-Tab
// Heuristik: Anzeigename winget-ID-Vorschlag
function guessWingetId(name) {
// Versionsnummer am Ende entfernen: "Mozilla Firefox 134.0.1" "Mozilla Firefox"
const clean = name.replace(/\s+\d[\d.]*(\s+\(.*\))?$/, '').trim()
// "Publisher Name" "Publisher.Name" (erste zwei Wörter)
const parts = clean.split(/\s+/)
if (parts.length >= 2) return `${parts[0]}.${parts.slice(1).join('')}`
return clean
}
function WauTab({ customerId, customerNumber }) {
const [rules, setRules] = useState(null)
const [inventory, setInventory] = useState(null)
const [invSearch, setInvSearch] = useState('')
const [loadingInv, setLoadingInv] = useState(false)
const [copied, setCopied] = useState(false)
const [externalUrl, setExternalUrl] = useState('')
// Inline-Assign: { index, ruleType, wingetId, displayName }
const [pending, setPending] = useState(null)
const [saving, setSaving] = useState(false)
// Manuelles Formular (Fallback)
const [showManual, setShowManual] = useState(false)
const [manualForm, setManualForm] = useState({ wingetId: '', displayName: '', ruleType: 'allow' })
// Compliance
const [compliance, setCompliance] = useState(null)
const [loadingComp, setLoadingComp] = useState(false)
const [showCompliance, setShowCompliance] = useState(false)
const reload = () => api.getWAURules(customerId).then(setRules)
const loadInventory = () => {
setLoadingInv(true)
api.getWAUInventory(customerId).then(setInventory).finally(() => setLoadingInv(false))
}
const loadCompliance = () => {
setLoadingComp(true)
api.getWAUCompliance(customerId).then(setCompliance).finally(() => setLoadingComp(false))
}
useEffect(() => {
reload()
loadInventory()
loadCompliance()
api.getSettings().then(s => {
const url = s?.data?.backend_url_external || s?.backend_url_external
if (url) setExternalUrl(url.replace(/\/$/, ''))
})
}, [customerId])
const handleDelete = async (ruleId) => {
await api.deleteWAURule(customerId, ruleId)
reload()
}
// Inline-Assign starten
const startAssign = (entry, idx, ruleType) => {
setPending({ index: idx, ruleType, wingetId: guessWingetId(entry.name), displayName: entry.name })
}
// Inline-Assign bestätigen
const confirmAssign = async () => {
if (!pending?.wingetId.trim()) return
setSaving(true)
try {
await api.addWAURule(customerId, pending.wingetId.trim(), pending.displayName.trim(), pending.ruleType)
setPending(null)
reload()
} finally {
setSaving(false)
}
}
// Manuell hinzufügen
const handleManualAdd = async () => {
if (!manualForm.wingetId.trim()) return
setSaving(true)
try {
await api.addWAURule(customerId, manualForm.wingetId.trim(), manualForm.displayName.trim(), manualForm.ruleType)
setManualForm({ wingetId: '', displayName: '', ruleType: 'allow' })
setShowManual(false)
reload()
} finally {
setSaving(false)
}
}
const allowRules = (rules || []).filter(r => r.rule_type === 'allow')
const blockRules = (rules || []).filter(r => r.rule_type === 'block')
const filteredInv = (inventory || []).filter(e =>
!invSearch ||
e.name.toLowerCase().includes(invSearch.toLowerCase()) ||
(e.publisher || '').toLowerCase().includes(invSearch.toLowerCase())
)
const baseUrl = externalUrl || window.location.origin
const wauCmd = externalUrl
? `winget install Romanitho.Winget-AutoUpdate --silent --accept-package-agreements --accept-source-agreements --override "/qn NOTIFICATIONLEVEL=2 RUNONSTARTUP=1 UPDATESINTERVAL=Daily LISTPATH=${baseUrl}/api/v1/customers/${customerId}/wau/included BLOCKEDAPPSLISTPATH=${baseUrl}/api/v1/customers/${customerId}/wau/excluded"`
: '⚠ Externe Backend-URL nicht konfiguriert — bitte unter Einstellungen → Backend-URL (extern) eintragen'
const copyCmd = () => {
copyToClipboard(wauCmd)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="px-6 py-4 space-y-4">
{/* WAU Compliance Status */}
<WAUCompliancePanel compliance={compliance} loading={loadingComp} onRefresh={loadCompliance} />
{/* Installationsbefehl */}
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-400 font-medium">WAU-Installationsbefehl</span>
<button onClick={copyCmd} className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-orange-400 transition-colors">
{copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
{copied ? 'Kopiert' : 'Kopieren'}
</button>
</div>
<code className="text-[10px] text-gray-400 font-mono break-all leading-relaxed">{wauCmd}</code>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Linke Spalte: Regeln */}
<div className="space-y-3">
<RuleList title="Allowlist" icon={ShieldCheck} color="text-green-400" rules={allowRules} onDelete={handleDelete} />
<RuleList title="Blocklist" icon={ShieldX} color="text-red-400" rules={blockRules} onDelete={handleDelete} />
{/* Manuell hinzufügen (Fallback) */}
<div>
<button
onClick={() => setShowManual(v => !v)}
className="text-[10px] text-gray-600 hover:text-gray-400 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Manuell per winget-ID hinzufügen
</button>
{showManual && (
<div className="mt-2 bg-gray-900 rounded-lg border border-gray-700 p-3 space-y-2">
<div className="flex gap-2">
<select
value={manualForm.ruleType}
onChange={e => setManualForm(f => ({ ...f, ruleType: e.target.value }))}
className="bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500 w-24 shrink-0"
>
<option value="allow">Allowlist</option>
<option value="block">Blocklist</option>
</select>
<input
value={manualForm.wingetId}
onChange={e => setManualForm(f => ({ ...f, wingetId: e.target.value }))}
placeholder="Mozilla.Firefox"
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white font-mono focus:outline-none focus:border-orange-500 min-w-0"
autoFocus
/>
</div>
<div className="flex gap-2">
<input
value={manualForm.displayName}
onChange={e => setManualForm(f => ({ ...f, displayName: e.target.value }))}
placeholder="Anzeigename (optional)"
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500"
/>
<button
onClick={handleManualAdd}
disabled={!manualForm.wingetId.trim() || saving}
className="flex items-center gap-1 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 rounded text-xs text-white transition-colors shrink-0"
>
<Check className="w-3 h-3" />
Speichern
</button>
</div>
</div>
)}
</div>
</div>
{/* Rechte Spalte: Software-Inventar */}
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden flex flex-col max-h-[520px]">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700 shrink-0">
<div className="flex items-center gap-1.5">
<Package className="w-3.5 h-3.5 text-gray-500" />
<span className="text-xs text-gray-400 font-medium">
Software-Inventar ({inventory ? filteredInv.length : '…'})
</span>
</div>
<button onClick={loadInventory} disabled={loadingInv} className="text-gray-600 hover:text-gray-400">
<RefreshCw className={`w-3 h-3 ${loadingInv ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="px-2 py-1.5 border-b border-gray-800 shrink-0">
<div className="relative">
<Search className="absolute left-2 top-1.5 w-3 h-3 text-gray-600" />
<input
value={invSearch}
onChange={e => setInvSearch(e.target.value)}
placeholder="Suchen..."
className="w-full pl-6 pr-2 py-1 bg-gray-800 border border-gray-700 rounded text-xs text-white focus:outline-none focus:border-orange-500"
/>
</div>
</div>
<div className="overflow-y-auto flex-1">
{loadingInv ? (
<div className="px-3 py-4 text-xs text-gray-500">Lade Inventar...</div>
) : !inventory || inventory.length === 0 ? (
<div className="px-3 py-4 text-xs text-gray-600">
Kein Inventar Agent noch nicht aktualisiert oder kein Heartbeat.
</div>
) : (
<table className="w-full text-[11px]">
<tbody>
{filteredInv.map((e, i) => {
const existingRule = (rules || []).find(r => r.display_name === e.name)
const isPending = pending?.index === i
return (
<>
{/* Haupt-Zeile */}
<tr
key={`row-${i}`}
className={`border-b border-gray-800/50 group transition-colors ${isPending ? 'bg-gray-800/60' : 'hover:bg-gray-800/40'}`}
>
<td className="px-3 py-1.5 w-full">
<div className="text-gray-300 truncate" title={e.name}>{e.name}</div>
{e.publisher && <div className="text-gray-600 text-[10px] truncate">{e.publisher}</div>}
</td>
<td className="px-1 py-1.5 text-gray-600 text-right shrink-0 whitespace-nowrap">
<span className="text-[10px]">{e.device_count}x</span>
</td>
<td className="px-2 py-1.5 shrink-0 whitespace-nowrap">
{existingRule ? (
// Schon zugeordnet Badge + Entfernen
<div className="flex items-center gap-1">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
existingRule.rule_type === 'allow'
? 'bg-green-900/40 text-green-400'
: 'bg-red-900/40 text-red-400'
}`}>
{existingRule.rule_type === 'allow' ? 'Allow' : 'Block'}
</span>
<button
onClick={() => handleDelete(existingRule.id)}
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
title="Regel entfernen"
>
<X className="w-3 h-3" />
</button>
</div>
) : isPending ? (
// Inline-Assign offen Abbrechen
<button onClick={() => setPending(null)} className="text-gray-500 hover:text-gray-300">
<X className="w-3.5 h-3.5" />
</button>
) : (
// Allow / Block Buttons on hover
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button
onClick={() => startAssign(e, i, 'allow')}
className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 hover:bg-green-800/50 font-medium"
title="Zur Allowlist hinzufügen"
>
Allow
</button>
<button
onClick={() => startAssign(e, i, 'block')}
className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/30 text-red-400 hover:bg-red-800/50 font-medium"
title="Zur Blocklist hinzufügen"
>
Block
</button>
</div>
)}
</td>
</tr>
{/* Inline-Assign Zeile */}
{isPending && (
<tr key={`pending-${i}`} className="border-b border-orange-500/30 bg-gray-800/80">
<td colSpan={3} className="px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-bold shrink-0 px-1.5 py-0.5 rounded ${
pending.ruleType === 'allow'
? 'bg-green-900/40 text-green-400'
: 'bg-red-900/40 text-red-400'
}`}>
{pending.ruleType === 'allow' ? 'Allow' : 'Block'}
</span>
<input
value={pending.wingetId}
onChange={e => setPending(p => ({ ...p, wingetId: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter') confirmAssign(); if (e.key === 'Escape') setPending(null) }}
placeholder="winget-ID bestätigen"
className="flex-1 bg-gray-700 border border-orange-500/50 rounded px-2 py-1 text-xs text-white font-mono focus:outline-none focus:border-orange-400 min-w-0"
autoFocus
/>
<button
onClick={confirmAssign}
disabled={!pending.wingetId.trim() || saving}
className="shrink-0 p-1 rounded bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 text-white transition-colors"
title="Speichern"
>
<Check className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[10px] text-gray-500 mt-1 pl-[60px]">
Winget-ID anpassen falls nötig, dann bestätigen (Enter)
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
)
}
// RuleList
function RuleList({ title, icon: Icon, color, rules, onDelete }) {
return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-gray-700">
<Icon className={`w-3.5 h-3.5 ${color}`} />
<span className="text-xs text-gray-400 font-medium">{title}</span>
<span className="ml-auto text-[10px] text-gray-600">{rules.length} Einträge</span>
</div>
{rules.length === 0 ? (
<div className="px-3 py-3 text-xs text-gray-600">Keine Einträge</div>
) : (
<div className="divide-y divide-gray-800">
{rules.map(r => (
<div key={r.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-800/40 group">
<div className="flex-1 min-w-0">
<div className="text-xs text-white truncate">{r.display_name || r.winget_id}</div>
<div className="text-[10px] text-gray-600 font-mono">{r.winget_id}</div>
</div>
<button
onClick={() => onDelete(r.id)}
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -3,7 +3,8 @@ import api from '../api/client'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import AgentPanel from '../components/AgentPanel' import AgentPanel from '../components/AgentPanel'
import ProxmoxPanel from '../components/ProxmoxPanel' import ProxmoxPanel from '../components/ProxmoxPanel'
import { Server, HardDrive, Users, Activity, AlertTriangle } from 'lucide-react' import WindowsPanel from '../components/WindowsPanel'
import { Server, HardDrive, Users, Activity, AlertTriangle, Monitor } from 'lucide-react'
export default function Dashboard() { export default function Dashboard() {
const [agents, setAgents] = useState([]) const [agents, setAgents] = useState([])
@ -11,7 +12,7 @@ export default function Dashboard() {
const [agentDetails, setAgentDetails] = useState({}) const [agentDetails, setAgentDetails] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState(null) const [selectedId, setSelectedId] = useState(null)
const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox' const [selectedType, setSelectedType] = useState(null) // 'device' | 'proxmox' | 'windows'
const reload = () => { const reload = () => {
Promise.all([api.getAgents(), api.getCustomers()]) Promise.all([api.getAgents(), api.getCustomers()])
@ -34,6 +35,11 @@ export default function Dashboard() {
const d = agentDetails[agentId] const d = agentDetails[agentId]
return d?.system_data?.proxmox?.available === true return d?.system_data?.proxmox?.available === true
} }
const getAgentType = (agent) => {
if (isProxmox(agent.id)) return 'proxmox'
if (agent.platform === 'windows') return 'windows'
return 'device'
}
useEffect(() => { useEffect(() => {
reload() reload()
@ -89,7 +95,7 @@ export default function Dashboard() {
</div> </div>
<div className="divide-y divide-gray-800"> <div className="divide-y divide-gray-800">
{customer.agents.map((agent) => ( {customer.agents.map((agent) => (
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} /> <AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(getAgentType(agent)) }} selected={selectedId === agent.id} />
))} ))}
</div> </div>
</div> </div>
@ -105,14 +111,14 @@ export default function Dashboard() {
{agents {agents
.filter((a) => !a.customer_id) .filter((a) => !a.customer_id)
.map((agent) => ( .map((agent) => (
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} /> <AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(getAgentType(agent)) }} selected={selectedId === agent.id} />
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Detail Panel — Geraet-Detail */} {/* Detail Panels */}
{selectedId && selectedType === 'proxmox' && ( {selectedId && selectedType === 'proxmox' && (
<ProxmoxPanel <ProxmoxPanel
agentId={selectedId} agentId={selectedId}
@ -121,7 +127,15 @@ export default function Dashboard() {
onReload={reload} onReload={reload}
/> />
)} )}
{selectedId && selectedType !== 'proxmox' && ( {selectedId && selectedType === 'windows' && (
<WindowsPanel
agentId={selectedId}
customers={customers}
onClose={() => { setSelectedId(null); setSelectedType(null) }}
onReload={reload}
/>
)}
{selectedId && selectedType === 'device' && (
<AgentPanel <AgentPanel
agentId={selectedId} agentId={selectedId}
customers={customers} customers={customers}
@ -161,8 +175,12 @@ function AgentRow({ agent, isPve, onClick, selected }) {
<StatusBadge status={agent.status} size="dot" /> <StatusBadge status={agent.status} size="dot" />
<div> <div>
<div className="text-sm text-white inline-flex items-center gap-2"> <div className="text-sm text-white inline-flex items-center gap-2">
<span className={`text-[10px] font-mono px-1 rounded ${agent.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}> <span className={`text-[10px] font-mono px-1 rounded ${
{agent.platform === 'linux' ? 'LNX' : 'OPN'} agent.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' :
agent.platform === 'windows' ? 'bg-sky-900/50 text-sky-400' :
'bg-orange-900/50 text-orange-400'
}`}>
{agent.platform === 'linux' ? 'LNX' : agent.platform === 'windows' ? 'WIN' : 'OPN'}
</span> </span>
{agent.name} {agent.name}
</div> </div>

View File

@ -16,6 +16,7 @@ export default function Firmware() {
const [firmwareData, setFirmwareData] = useState(null) const [firmwareData, setFirmwareData] = useState(null)
const [installerData, setInstallerData] = useState(null) const [installerData, setInstallerData] = useState(null)
const [agents, setAgents] = useState([]) const [agents, setAgents] = useState([])
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [installerUploading, setInstallerUploading] = useState(false) const [installerUploading, setInstallerUploading] = useState(false)
@ -30,14 +31,16 @@ export default function Firmware() {
const load = async () => { const load = async () => {
try { try {
const [fw, ag, inst] = await Promise.all([ const [fw, ag, inst, cu] = await Promise.all([
api.getFirmwareInfo().catch(() => null), api.getFirmwareInfo().catch(() => null),
api.getAgents().catch(() => []), api.getAgents().catch(() => []),
api.getInstallerInfo().catch(() => null), api.getInstallerInfo().catch(() => null),
api.getCustomers().catch(() => []),
]) ])
setFirmwareData(fw) setFirmwareData(fw)
setAgents(Array.isArray(ag) ? ag : []) setAgents(Array.isArray(ag) ? ag : [])
setInstallerData(inst) setInstallerData(inst)
setCustomers(Array.isArray(cu) ? cu : [])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -228,7 +231,9 @@ export default function Firmware() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="w-4 h-4 text-orange-400" /> <Package className="w-4 h-4 text-orange-400" />
<span className="text-white text-sm font-medium">{inst.platform === 'linux' ? 'Linux' : 'OPNsense / FreeBSD'}</span> <span className="text-white text-sm font-medium">
{inst.platform === 'linux' ? 'Linux' : inst.platform === 'windows' ? 'Windows' : 'OPNsense / FreeBSD'}
</span>
</div> </div>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
{inst.filename} {formatSize(inst.size)} {new Date(inst.uploaded_at).toLocaleString('de-DE')} {inst.filename} {formatSize(inst.size)} {new Date(inst.uploaded_at).toLocaleString('de-DE')}
@ -258,6 +263,23 @@ export default function Firmware() {
</div> </div>
)} )}
{installerData?.installers?.some(i => i.platform === 'windows') && (
<div className="mb-4 bg-gray-950 rounded p-3 relative">
<div className="text-[10px] text-gray-500 mb-1">Installationsbefehl (Windows, als Admin in PowerShell):</div>
<pre className="text-xs font-mono text-sky-300 whitespace-pre-wrap">{`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; $wc=New-Object Net.WebClient; $wc.Headers["X-API-Key"]="YOUR_AGENT_KEY"; $wc.DownloadFile("${BACKEND_URL}/api/v1/installer/download?platform=windows","C:\\rmm-install.zip"); Expand-Archive -Path C:\\rmm-install.zip -DestinationPath C:\\rmm-install -Force; Set-ExecutionPolicy Bypass -Scope Process -Force; C:\\rmm-install\\install.ps1 -BackendURL "${BACKEND_URL}" -APIKey "YOUR_AGENT_KEY" -AgentName "PC-NAME"`}</pre>
<button
onClick={() => {
copyToClipboard(`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; $wc=New-Object Net.WebClient; $wc.Headers["X-API-Key"]="YOUR_AGENT_KEY"; $wc.DownloadFile("${BACKEND_URL}/api/v1/installer/download?platform=windows","C:\\rmm-install.zip"); Expand-Archive -Path C:\\rmm-install.zip -DestinationPath C:\\rmm-install -Force; Set-ExecutionPolicy Bypass -Scope Process -Force; C:\\rmm-install\\install.ps1 -BackendURL "${BACKEND_URL}" -APIKey "YOUR_AGENT_KEY" -AgentName "PC-NAME"`)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className="absolute top-2 right-2 text-gray-600 hover:text-sky-400 transition-colors"
>
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
{/* Upload */} {/* Upload */}
<div className="flex flex-wrap gap-3 items-end"> <div className="flex flex-wrap gap-3 items-end">
<select <select
@ -267,6 +289,7 @@ export default function Firmware() {
> >
<option value="freebsd">OPNsense / FreeBSD</option> <option value="freebsd">OPNsense / FreeBSD</option>
<option value="linux">Linux</option> <option value="linux">Linux</option>
<option value="windows">Windows</option>
</select> </select>
<input <input
type="file" type="file"
@ -299,14 +322,16 @@ export default function Firmware() {
</div> </div>
</div> </div>
{/* Agent List */} {/* Agent List — nach Kunde gruppiert */}
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5"> <div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between mb-4"> <div className="px-4 py-2.5 border-b border-gray-800 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Agents</h2> <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Agents ({agents.length})
</span>
{hasAnyFirmware && ( {hasAnyFirmware && (
<button <button
onClick={requestAll} onClick={requestAll}
className="flex items-center gap-2 bg-orange-600/20 hover:bg-orange-600/30 text-orange-400 px-3 py-1.5 rounded text-xs transition-colors border border-orange-600/30" className="flex items-center gap-1.5 text-orange-400 hover:text-orange-300 text-xs transition-colors"
> >
<ArrowUpCircle className="w-3.5 h-3.5" /> <ArrowUpCircle className="w-3.5 h-3.5" />
Alle updaten Alle updaten
@ -314,66 +339,81 @@ export default function Firmware() {
)} )}
</div> </div>
<div className="space-y-2"> {(() => {
{agents.map((agent) => { // Gruppieren nach customer_id
const groups = {}
agents.forEach(a => {
const gid = a.customer_id ?? '__none__'
if (!groups[gid]) groups[gid] = []
groups[gid].push(a)
})
const sorted = Object.entries(groups).sort(([a], [b]) => {
if (a === '__none__') return 1
if (b === '__none__') return -1
const ca = customers.find(c => c.id === parseInt(a))
const cb = customers.find(c => c.id === parseInt(b))
return (ca?.number || '').localeCompare(cb?.number || '')
})
return sorted.map(([gid, gAgents]) => {
const customer = gid !== '__none__' ? customers.find(c => c.id === parseInt(gid)) : null
return (
<div key={gid}>
<div className="px-4 py-1 bg-gray-800/40 border-b border-gray-800/60">
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">
{customer ? `${customer.number}${customer.name}` : 'Kein Kunde'}
</span>
<span className="text-[10px] text-gray-700 ml-1.5">({gAgents.length})</span>
</div>
{gAgents.map(agent => {
const outdated = needsUpdate(agent) const outdated = needsUpdate(agent)
const pending = agent.update_requested const pending = agent.update_requested
const agentPlatform = agent.platform || 'freebsd' const agentPlatform = agent.platform || 'freebsd'
const fw = getFwForAgent(agent) const fw = getFwForAgent(agent)
const platformLabel = agentPlatform === 'linux' ? 'Linux' : 'FreeBSD' const isLinux = agentPlatform === 'linux'
const platformColor = agentPlatform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400' const isWindows = agentPlatform === 'windows'
const platformLabel = isWindows ? 'WIN' : isLinux ? 'LNX' : 'BSD'
const platformColor = isWindows
? 'text-sky-400 bg-sky-900/30'
: isLinux
? 'text-blue-400 bg-blue-900/30'
: 'text-orange-400 bg-orange-900/30'
return ( return (
<div key={agent.id} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30"> <div key={agent.id} className="px-4 py-1.5 flex items-center gap-2.5 hover:bg-gray-800/30 border-b border-gray-800/30 last:border-0">
<div className="min-w-0 flex-1"> <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
<div className="flex items-center gap-2"> agent.status === 'online' ? 'bg-green-400' :
<span className={`w-2 h-2 rounded-full shrink-0 ${ agent.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
agent.status === 'online' ? 'bg-emerald-500' :
agent.status === 'stale' ? 'bg-yellow-500' : 'bg-gray-600'
}`} /> }`} />
<span className="text-white text-sm font-medium truncate">{agent.name}</span> <span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{agent.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${platformColor}`}>{platformLabel}</span> <span className={`text-[10px] px-1 py-0.5 rounded flex-shrink-0 ${platformColor}`}>
</div> {platformLabel}
<div className="flex items-center gap-3 mt-1"> </span>
<span className="text-xs text-gray-500"> <span className={`text-[10px] font-mono flex-shrink-0 ${outdated ? 'text-yellow-400' : 'text-gray-600'}`}>
Version: <span className={outdated ? 'text-yellow-400' : 'text-gray-400'}>{agent.agent_version || '--'}</span> v{agent.agent_version || '—'}
{fw && <span className="text-gray-600 ml-1">(aktuell: v{fw.version})</span>} {fw && outdated && <span className="text-gray-600"> v{fw.version}</span>}
{!fw && <span className="text-gray-600 ml-1">(keine Firmware fuer {platformLabel})</span>}
</span> </span>
{pending && ( {pending && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 border border-orange-500/30"> <span className="text-[10px] text-orange-400 flex-shrink-0">ausstehend</span>
Update ausstehend
</span>
)} )}
{!outdated && fw && agent.agent_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Aktuell
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
{pending ? ( {pending ? (
<button <button onClick={() => cancelUpdate(agent.id, agent.name)}
onClick={() => cancelUpdate(agent.id, agent.name)} className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0" title="Abbrechen">
className="flex items-center gap-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1.5 rounded text-xs transition-colors"
>
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
Abbrechen
</button> </button>
) : outdated ? ( ) : outdated ? (
<button <button onClick={() => requestUpdate(agent.id, agent.name)}
onClick={() => requestUpdate(agent.id, agent.name)} className="text-orange-400 hover:text-orange-300 transition-colors flex-shrink-0" title="Update anfordern">
className="flex items-center gap-1.5 bg-orange-600 hover:bg-orange-500 text-white px-3 py-1.5 rounded text-xs transition-colors"
>
<ArrowUpCircle className="w-3.5 h-3.5" /> <ArrowUpCircle className="w-3.5 h-3.5" />
Update
</button> </button>
) : fw ? (
<CheckCircle className="w-3.5 h-3.5 text-green-700 flex-shrink-0" />
) : null} ) : null}
</div> </div>
</div>
) )
})} })}
</div> </div>
)
})
})()}
</div> </div>
</div> </div>
) )

View File

@ -199,18 +199,28 @@ export default function SettingsPage() {
) )
} }
const PERM_LABELS = {
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50', desc: 'Nur WS-Connect + Heartbeat' },
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50', desc: 'GET-Endpoints, kein Schreiben' },
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50', desc: 'Lesen + Schreiben, kein Terminal' },
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50', desc: 'Voll (kein Terminal)' },
}
function APIKeysSection() { function APIKeysSection() {
const [keys, setKeys] = useState([]) const [keys, setKeys] = useState([])
const [customers, setCustomers] = useState([])
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [newPerms, setNewPerms] = useState('read')
const [newCustomer, setNewCustomer] = useState('')
const [newKey, setNewKey] = useState(null) const [newKey, setNewKey] = useState(null)
const [copied, setCopied] = useState(null) const [copied, setCopied] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const reload = () => { const reload = () => {
setLoading(true) setLoading(true)
api.getAPIKeys() Promise.all([api.getAPIKeys(), api.getCustomers()])
.then(k => setKeys(k || [])) .then(([k, c]) => { setKeys(k || []); setCustomers(c || []) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
useEffect(() => { reload() }, []) useEffect(() => { reload() }, [])
@ -218,9 +228,11 @@ function APIKeysSection() {
const handleCreate = async () => { const handleCreate = async () => {
if (!newName.trim()) return if (!newName.trim()) return
try { try {
const result = await api.createAPIKey(newName.trim()) const result = await api.createAPIKey(newName.trim(), newPerms, newCustomer ? parseInt(newCustomer) : null)
setNewKey(result) setNewKey(result)
setNewName('') setNewName('')
setNewPerms('read')
setNewCustomer('')
reload() reload()
} catch (e) { } catch (e) {
alert('Fehler: ' + e.message) alert('Fehler: ' + e.message)
@ -258,7 +270,8 @@ function APIKeysSection() {
{/* Neuer Key Dialog */} {/* Neuer Key Dialog */}
{showAdd && !newKey && ( {showAdd && !newKey && (
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end"> <div className="px-4 py-3 border-b border-gray-800 space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1"> <div className="flex-1">
<label className="text-xs text-gray-500">Bezeichnung</label> <label className="text-xs text-gray-500">Bezeichnung</label>
<input <input
@ -271,13 +284,43 @@ function APIKeysSection() {
onKeyDown={(e) => e.key === 'Enter' && handleCreate()} onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/> />
</div> </div>
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white"> <div className="w-36">
<label className="text-xs text-gray-500">Berechtigung</label>
<select
value={newPerms}
onChange={(e) => setNewPerms(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
>
<option value="agent">Agent (nur WS)</option>
<option value="read">Read-only</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="w-48">
<label className="text-xs text-gray-500">Kunde (optional)</label>
<select
value={newCustomer}
onChange={(e) => setNewCustomer(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
>
<option value="">Alle Kunden</option>
{customers.map(c => (
<option key={c.id} value={c.id}>{c.number} {c.name}</option>
))}
</select>
</div>
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white self-end">
Erstellen Erstellen
</button> </button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400"> <button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 self-end">
Abbrechen Abbrechen
</button> </button>
</div> </div>
{newPerms && PERM_LABELS[newPerms] && (
<div className="text-xs text-gray-500 pl-1">{PERM_LABELS[newPerms].desc}</div>
)}
</div>
)} )}
{/* Neuer Key Anzeige */} {/* Neuer Key Anzeige */}
@ -304,49 +347,86 @@ function APIKeysSection() {
</div> </div>
)} )}
{/* Key Liste */} {/* Key Liste — gruppiert nach Kunde */}
{loading ? ( {loading ? (
<div className="px-4 py-3 text-gray-500 text-sm">Laden...</div> <div className="px-4 py-3 text-gray-500 text-xs">Laden...</div>
) : keys.length === 0 ? ( ) : keys.length === 0 ? (
<div className="px-4 py-6 text-center text-gray-500 text-sm">Keine API-Keys vorhanden</div> <div className="px-4 py-4 text-center text-gray-500 text-xs">Keine API-Keys vorhanden</div>
) : ( ) : (() => {
// Gruppen bauen: null-Kunde zuerst, dann nach Kundennummer sortiert
const groups = {}
keys.forEach(k => {
const gid = k.customer_id ?? '__global__'
if (!groups[gid]) groups[gid] = []
groups[gid].push(k)
})
const sorted = Object.entries(groups).sort(([a], [b]) => {
if (a === '__global__') return -1
if (b === '__global__') return 1
const ca = customers.find(c => c.id === parseInt(a))
const cb = customers.find(c => c.id === parseInt(b))
return (ca?.number || '').localeCompare(cb?.number || '')
})
return (
<div className="divide-y divide-gray-800"> <div className="divide-y divide-gray-800">
{keys.map((k) => ( {sorted.map(([gid, gkeys]) => {
<div key={k.id} className="px-4 py-2.5 flex items-center justify-between"> const customer = gid !== '__global__' ? customers.find(c => c.id === parseInt(gid)) : null
<div className="min-w-0 flex-1"> return (
<div className="flex items-center gap-2"> <div key={gid}>
<span className="text-sm text-white">{k.name}</span> {/* Gruppen-Header */}
</div> <div className="px-4 py-1.5 bg-gray-800/40 flex items-center gap-2">
<div className="flex items-center gap-3 mt-0.5"> <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">
<span className="text-xs text-gray-600 font-mono">{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}</span> {customer ? `${customer.number}${customer.name}` : 'Global'}
<button
onClick={() => copyText(k.key, k.id)}
className="text-gray-600 hover:text-orange-400 transition-colors"
title="Key kopieren"
>
{copied === k.id ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
</button>
<span className="text-xs text-gray-600">
Erstellt: {new Date(k.created_at).toLocaleDateString('de-DE')}
</span> </span>
{k.last_used && ( <span className="text-[10px] text-gray-700">({gkeys.length})</span>
<span className="text-xs text-gray-600"> </div>
Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')} {/* Keys der Gruppe */}
{gkeys.map(k => {
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
const lastUsedAgo = k.last_used
? (() => {
const s = (Date.now() - new Date(k.last_used)) / 1000
if (s < 120) return 'gerade eben'
if (s < 3600) return `${Math.floor(s/60)}m`
if (s < 86400) return `${Math.floor(s/3600)}h`
return `${Math.floor(s/86400)}d`
})()
: null
return (
<div key={k.id} className="px-4 py-1.5 flex items-center gap-2.5 hover:bg-gray-800/30 group">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
{perm.label}
</span>
<span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{k.name}</span>
<code className="text-xs text-gray-600 font-mono flex-shrink-0">
{k.key.substring(0, 8)}{k.key.substring(k.key.length - 4)}
</code>
{lastUsedAgo && (
<span className="text-[10px] text-gray-700 flex-shrink-0 hidden md:block"
title={`Zuletzt: ${new Date(k.last_used).toLocaleString('de-DE')}`}>
{lastUsedAgo}
</span> </span>
)} )}
</div> <button onClick={() => copyText(k.key, k.id)}
</div> className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0 opacity-0 group-hover:opacity-100"
<button title="Key kopieren">
onClick={() => handleDelete(k.id, k.name)} {copied === k.id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-800 transition-colors ml-2" </button>
title="Key loeschen" <button onClick={() => handleDelete(k.id, k.name)}
> className="text-gray-700 hover:text-red-400 transition-colors flex-shrink-0 opacity-0 group-hover:opacity-100"
<Trash2 className="w-4 h-4" /> title="Key loeschen">
<Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
))} )
})}
</div> </div>
)} )
})}
</div>
)
})()
}
</div> </div>
) )
} }

View File

@ -0,0 +1,321 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import api from '../api/client'
import StatusBadge from '../components/StatusBadge'
import WindowsPanel from '../components/WindowsPanel'
import { Search, ChevronUp, ChevronDown, Settings2, X } from 'lucide-react'
const ALL_COLUMNS = [
{ id: 'customer', label: 'Kunde', default: true },
{ id: 'name', label: 'Name', default: true, locked: true },
{ id: 'hostname', label: 'Hostname', default: false },
{ id: 'os', label: 'OS', default: true },
{ id: 'version', label: 'Agent Version', default: true },
{ id: 'ip', label: 'IP', default: true },
{ id: 'uptime', label: 'Uptime', default: true },
{ id: 'cpu', label: 'CPU', default: true },
{ id: 'ram', label: 'RAM', default: true },
{ id: 'disk', label: 'Disk (C:)', default: true },
{ id: 'lastresponse', label: 'Letzter Kontakt', default: false },
]
const STORAGE_KEY = 'rmm-windows-columns'
function loadColumns() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) return JSON.parse(saved)
} catch {}
return ALL_COLUMNS.filter(c => c.default).map(c => c.id)
}
function saveColumns(cols) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cols))
}
function formatUptime(seconds) {
if (!seconds) return '—'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
return `${h}h ${m}m`
}
export default function Windows() {
const [agents, setAgents] = useState([])
const [customers, setCustomers] = useState([])
const [agentDetails, setAgentDetails] = useState({})
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [sortKey, setSortKey] = useState('customer')
const [sortDir, setSortDir] = useState('asc')
const [selectedId, setSelectedId] = useState(null)
const [visibleCols, setVisibleCols] = useState(loadColumns)
const [showColMenu, setShowColMenu] = useState(false)
const colMenuRef = useRef(null)
const reload = () => {
Promise.all([api.getAgents(), api.getCustomers()])
.then(([a, c]) => {
setAgents((a || []).filter(ag => ag.platform === 'windows'))
setCustomers(c || [])
})
.finally(() => setLoading(false))
}
useEffect(() => {
reload()
const iv = setInterval(reload, 30000)
return () => clearInterval(iv)
}, [])
useEffect(() => {
agents.forEach(a => {
if (!agentDetails[a.id]) {
api.getAgent(a.id).then(d =>
setAgentDetails(prev => ({ ...prev, [a.id]: d }))
)
}
})
}, [agents])
// Close column menu on click outside
useEffect(() => {
const handler = (e) => {
if (colMenuRef.current && !colMenuRef.current.contains(e.target)) setShowColMenu(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const customerMap = useMemo(() =>
Object.fromEntries((customers || []).map(c => [c.id, c])), [customers])
const toggleSort = (key) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortKey(key); setSortDir('asc') }
}
const toggleCol = (id) => {
const col = ALL_COLUMNS.find(c => c.id === id)
if (col?.locked) return
const next = visibleCols.includes(id)
? visibleCols.filter(c => c !== id)
: [...visibleCols, id]
setVisibleCols(next)
saveColumns(next)
}
const isColVisible = (id) => visibleCols.includes(id)
const enriched = useMemo(() => agents.map(a => {
const d = agentDetails[a.id]
const win = d?.system_data?.windows
const cust = a.customer_id ? customerMap[a.customer_id] : null
const memPct = win?.memory?.total_bytes > 0
? ((win.memory.total_bytes - (win.memory.available_bytes ?? 0)) / win.memory.total_bytes * 100)
: null
// Ersten FIXED-Disk nehmen (meistens C:)
const disk = win?.disks?.[0]
const diskPct = disk?.used_percent ?? null
return {
...a,
customer: cust,
customerName: cust?.name || '',
customerNumber: cust?.number || '',
os: win?.os_version || '—',
cpuPct: win?.cpu?.load_percent ?? null,
memPct,
diskPct,
uptime: win?.uptime_seconds || 0,
}
}), [agents, agentDetails, customerMap])
const filtered = useMemo(() => {
let list = enriched
if (search) {
const q = search.toLowerCase()
list = list.filter(a =>
a.name.toLowerCase().includes(q) ||
(a.ip || '').toLowerCase().includes(q) ||
(a.hostname || '').toLowerCase().includes(q) ||
a.customerName.toLowerCase().includes(q) ||
a.customerNumber.toLowerCase().includes(q)
)
}
list = [...list].sort((a, b) => {
let va, vb
switch (sortKey) {
case 'customer': va = a.customerNumber; vb = b.customerNumber; break
case 'name': va = a.name; vb = b.name; break
case 'os': va = a.os; vb = b.os; break
case 'version': va = a.agent_version || ''; vb = b.agent_version || ''; break
case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime
case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1)
case 'ram': return sortDir === 'asc' ? (a.memPct ?? 999) - (b.memPct ?? 999) : (b.memPct ?? -1) - (a.memPct ?? -1)
case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1)
case 'status': va = a.status; vb = b.status; break
default: va = a.name; vb = b.name
}
if (typeof va === 'string') {
const cmp = va.localeCompare(vb)
return sortDir === 'asc' ? cmp : -cmp
}
return 0
})
return list
}, [enriched, search, sortKey, sortDir])
const SortHeader = ({ label, k, className = '' }) => (
<th
className={`px-3 py-2 font-medium cursor-pointer hover:text-gray-300 select-none ${className}`}
onClick={() => toggleSort(k)}
>
<span className="inline-flex items-center gap-1">
{label}
{sortKey === k && (sortDir === 'asc'
? <ChevronUp className="w-3 h-3" />
: <ChevronDown className="w-3 h-3" />
)}
</span>
</th>
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Windows Agents ({agents.length})</h1>
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Suchen (Name, IP, Kunde)..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-sky-500"
/>
</div>
{/* Spalten-Toggle */}
<div className="relative" ref={colMenuRef}>
<button
onClick={() => setShowColMenu(!showColMenu)}
className="p-2 bg-gray-900 border border-gray-800 rounded hover:border-gray-600 text-gray-400 hover:text-white transition-colors"
title="Spalten konfigurieren"
>
<Settings2 className="w-4 h-4" />
</button>
{showColMenu && (
<div className="absolute right-0 top-full mt-1 z-50 bg-gray-900 border border-gray-700 rounded-lg shadow-xl py-2 w-52">
<div className="px-3 py-1 text-xs text-gray-500 font-medium">Sichtbare Spalten</div>
{ALL_COLUMNS.map(col => (
<label
key={col.id}
className={`flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-800 ${col.locked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="checkbox"
checked={visibleCols.includes(col.id)}
onChange={() => toggleCol(col.id)}
disabled={col.locked}
className="rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500 focus:ring-offset-0"
/>
<span className="text-gray-300">{col.label}</span>
</label>
))}
</div>
)}
</div>
</div>
{loading ? (
<div className="text-gray-500">Laden...</div>
) : (
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-x-auto">
<table className="w-full text-xs whitespace-nowrap">
<thead>
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
<th className="px-2 py-1.5 font-medium w-8"></th>
{isColVisible('customer') && <SortHeader label="KUNDE" k="customer" />}
{isColVisible('name') && <SortHeader label="NAME" k="name" />}
{isColVisible('hostname') && <th className="px-3 py-2 font-medium">HOSTNAME</th>}
{isColVisible('os') && <SortHeader label="OS" k="os" />}
{isColVisible('version') && <SortHeader label="AGENT" k="version" />}
{isColVisible('ip') && <th className="px-3 py-2 font-medium">IP</th>}
{isColVisible('uptime') && <SortHeader label="UPTIME" k="uptime" />}
{isColVisible('cpu') && <SortHeader label="CPU" k="cpu" />}
{isColVisible('ram') && <SortHeader label="RAM" k="ram" />}
{isColVisible('disk') && <SortHeader label="DISK" k="disk" />}
{isColVisible('lastresponse') && <th className="px-3 py-2 font-medium">LETZTER KONTAKT</th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{filtered.map(a => (
<tr
key={a.id}
onClick={() => setSelectedId(a.id)}
className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`}
>
<td className="px-2 py-1.5"><StatusBadge status={a.status} size="dot" /></td>
{isColVisible('customer') && (
<td className="px-3 py-1.5 text-gray-400">
{a.customer
? <span className="text-sky-400">{a.customerNumber}</span>
: <span className="text-gray-600"></span>}
</td>
)}
{isColVisible('name') && <td className="px-3 py-1.5 text-white font-medium">{a.name}</td>}
{isColVisible('hostname') && <td className="px-3 py-1.5 text-gray-400">{a.hostname || '—'}</td>}
{isColVisible('os') && <td className="px-3 py-1.5 text-gray-400">{a.os}</td>}
{isColVisible('version') && <td className="px-3 py-1.5 text-gray-500">{a.agent_version ? `v${a.agent_version}` : '—'}</td>}
{isColVisible('ip') && <td className="px-3 py-1.5 text-gray-400">{a.ip || '—'}</td>}
{isColVisible('uptime') && <td className="px-3 py-1.5 text-gray-400">{formatUptime(a.uptime)}</td>}
{isColVisible('cpu') && <td className="px-3 py-1.5"><MiniBar value={a.cpuPct} /></td>}
{isColVisible('ram') && <td className="px-3 py-1.5"><MiniBar value={a.memPct} /></td>}
{isColVisible('disk') && <td className="px-3 py-1.5"><MiniBar value={a.diskPct} /></td>}
{isColVisible('lastresponse') && (
<td className="px-3 py-1.5 text-gray-500">
{a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'}
</td>
)}
</tr>
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">
{search ? 'Keine Treffer' : 'Keine Windows Agents registriert'}
</div>
)}
</div>
)}
{selectedId && (
<WindowsPanel
agentId={selectedId}
customers={customers}
onClose={() => setSelectedId(null)}
onReload={reload}
/>
)}
</div>
)
}
function MiniBar({ value }) {
if (value === null || value === undefined) return <span className="text-gray-600 text-[10px]"></span>
const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500'
return (
<div className="flex items-center gap-1">
<div className="w-10 h-1 bg-gray-800 rounded overflow-hidden">
<div className={`h-full rounded ${color}`} style={{ width: `${Math.min(value, 100)}%` }} />
</div>
<span className="text-[10px] text-gray-400">{value.toFixed(0)}%</span>
</div>
)
}

View File

@ -1,16 +1,23 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendHost = env.VITE_BACKEND_HOST || 'localhost'
const backendPort = env.VITE_BACKEND_PORT || '8443'
const backendUrl = `https://${backendHost}:${backendPort}`
return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'https://your-backend:8443', // TODO: set your backend URL target: backendUrl,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
}, },
}, },
}
}) })