diff --git a/Makefile b/Makefile index 7da69a9..bf96585 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,15 @@ -.PHONY: all backend agent updater plugin clean certs deploy-backend deploy-agent +.PHONY: all backend agent agent-linux updater plugin clean certs deploy-backend deploy-agent BACKEND_BIN = build/rmm-backend AGENT_BIN = build/rmm-agent +AGENT_LINUX_BIN = build/rmm-agent-linux UPDATER_BIN = build/rmm-updater BACKEND_HOST = 192.168.85.13 AGENT_HOST = 192.168.85.33 SSH_USER = root -all: backend agent updater +all: backend agent agent-linux updater backend: @echo "==> Building Backend (linux/amd64)..." @@ -20,6 +21,11 @@ agent: cd agent && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_BIN) . @echo "==> $(AGENT_BIN) erstellt" +agent-linux: + @echo "==> Building Agent Linux (linux/amd64)..." + 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" + updater: @echo "==> Building Updater (freebsd/amd64)..." cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_BIN) . diff --git a/agent-linux/README.md b/agent-linux/README.md new file mode 100644 index 0000000..232a9d0 --- /dev/null +++ b/agent-linux/README.md @@ -0,0 +1,142 @@ +# RMM Agent Linux + +Linux/Proxmox Agent für das RMM-System. Sammelt Systemdaten und ermöglicht Remote-Management über WebSocket. + +## Features + +- **System-Monitoring**: CPU, Memory, Disk, Network, Services, Updates +- **Proxmox-Integration**: VMs, Container, Storage, Cluster, Subscription, ZFS Pools +- **Remote-Commands**: `exec` (beliebige Befehle), `reboot` +- **Agent-Updates**: Automatische Binary-Updates über WebSocket +- **Cross-Platform**: Funktioniert auf normalen Linux-Systemen und Proxmox VE + +## Unterstützte Systeme + +- Debian/Ubuntu (mit apt) +- Proxmox VE +- Andere Linux-Distributionen mit systemd + +## Installation + +1. **Binary kompilieren:** + ```bash + make agent-linux VERSION=1.0.0 + ``` + +2. **Auf Zielsystem installieren:** + ```bash + # Dateien kopieren + scp build/rmm-agent-linux root@target-host:/tmp/ + scp agent-linux/install.sh root@target-host:/tmp/ + scp agent-linux/rmm-agent-linux.service root@target-host:/tmp/ + scp agent-linux/config.yaml.example root@target-host:/tmp/ + + # Installation + ssh root@target-host + cd /tmp + chmod +x install.sh + ./install.sh + ``` + +3. **Konfiguration anpassen:** + ```bash + nano /etc/rmm/config.yaml + ``` + +4. **Service starten:** + ```bash + systemctl start rmm-agent-linux + systemctl status rmm-agent-linux + journalctl -u rmm-agent-linux -f + ``` + +## Konfiguration + +```yaml +backend_url: "https://your-rmm-backend.example.com" +api_key: "your-api-key-here" +interval_seconds: 60 +agent_name: "linux-server-01" +insecure: true # Für Entwicklung, false für Produktion +``` + +## CLI-Flags + +```bash +rmm-agent-linux [flags] + +Flags: + -config string + Path zur Konfigurationsdatei (default "/etc/rmm/config.yaml") + -insecure + TLS-Zertifikatpruefung deaktivieren + -version + Version anzeigen +``` + +## Proxmox-Features + +Wenn auf Proxmox VE installiert, sammelt der Agent zusätzlich: + +- **VMs/Container**: Status, Ressourcen-Usage, Uptime +- **Storage**: Verfügbarer Platz, Typen (ZFS, LVM, NFS, etc.) +- **Cluster**: Node-Status, Quorum +- **Subscription**: Lizenz-Status +- **ZFS**: Pool-Health, Scrub-Status, Größen + +## Systemd Service + +- Service Name: `rmm-agent-linux` +- Config: `/etc/rmm/config.yaml` +- Agent ID: `/etc/rmm/agent_id` (automatisch generiert) +- Logs: `journalctl -u rmm-agent-linux` + +## Remote-Commands + +Über WebSocket unterstützte Commands: + +- **exec**: Beliebige Shell-Befehle ausführen +- **reboot**: System-Neustart +- **agent_update**: Agent-Binary aktualisieren + +## Entwicklung + +```bash +# Dependencies installieren +cd agent-linux +go mod tidy + +# Entwicklung +go run . -config config.yaml.example + +# Cross-compile für Linux +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o rmm-agent-linux . + +# Mit Version +go build -ldflags "-X main.Version=1.0.0" -o rmm-agent-linux . +``` + +## Unterschiede zum FreeBSD/OPNsense Agent + +- **OS-Detection**: Linux-spezifische `/proc`-Dateien und `systemd` +- **Package-Updates**: `apt list --upgradable` statt `pkg` +- **Services**: `systemctl` statt `service` +- **Proxmox**: Zusätzliche `pvesh`-Integration für VMs/Container +- **Keine OPNsense-Features**: Kein WireGuard, DHCP, Certificates, etc. + +## Troubleshooting + +```bash +# Service Status +systemctl status rmm-agent-linux + +# Live-Logs +journalctl -u rmm-agent-linux -f + +# Config testen +rmm-agent-linux -config /etc/rmm/config.yaml -version + +# Manuelle Registrierung testen +rm /etc/rmm/agent_id +systemctl restart rmm-agent-linux +``` \ No newline at end of file diff --git a/agent-linux/client/client.go b/agent-linux/client/client.go new file mode 100644 index 0000000..61429f8 --- /dev/null +++ b/agent-linux/client/client.go @@ -0,0 +1,95 @@ +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 { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + } + + return &Client{ + baseURL: baseURL, + apiKey: apiKey, + httpClient: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + } +} + +// Register - Agent am Backend registrieren, gibt Agent-ID zurueck +func (c *Client) Register(req interface{}) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", err + } + + resp, err := c.doRequest("POST", "/api/v1/agent/register", body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("Registrierung fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("Response parsen: %w", err) + } + + return result.ID, nil +} + +// Heartbeat - Systemdaten an Backend senden +func (c *Client) Heartbeat(req interface{}) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + + resp, err := c.doRequest("POST", "/api/v1/agent/heartbeat", body) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Heartbeat fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + return 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) +} \ No newline at end of file diff --git a/agent-linux/collector/backups.go b/agent-linux/collector/backups.go new file mode 100644 index 0000000..4864c64 --- /dev/null +++ b/agent-linux/collector/backups.go @@ -0,0 +1,89 @@ +package collector + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strings" + "time" +) + +type BackupTask struct { + UPID string `json:"upid"` + Status string `json:"status"` + StartTime int64 `json:"starttime"` + EndTime int64 `json:"endtime"` + Node string `json:"node"` +} + +type BackupData struct { + Jobs []map[string]interface{} `json:"jobs"` + Tasks []BackupTask `json:"tasks"` +} + +func CollectBackups() *BackupData { + if _, err := os.Stat("/usr/bin/pvesh"); err != nil { + return nil + } + + result := &BackupData{ + Jobs: []map[string]interface{}{}, + Tasks: []BackupTask{}, + } + + // Hostname fuer Node-Filterung + hostname, _ := os.Hostname() + nodeName := strings.TrimSpace(hostname) + + // Backup Jobs aus Cluster-Config — nur Jobs die auf diesem Node laufen + cmd := exec.Command("/usr/bin/pvesh", "get", "/cluster/backup", "--output-format", "json") + if out, err := cmd.Output(); err == nil { + var allJobs []map[string]interface{} + if err := json.Unmarshal(out, &allJobs); err == nil { + for _, j := range allJobs { + // next-run -> next_run umbenennen fuer Frontend-Kompatibilitaet + if nr, ok := j["next-run"]; ok { + j["next_run"] = nr + delete(j, "next-run") + } + // Filtern: Job gehoert zu diesem Node wenn: + // - node == unser hostname + // - node leer/nicht gesetzt (= alle Nodes) + jobNode, _ := j["node"].(string) + if jobNode == "" || strings.EqualFold(jobNode, nodeName) { + result.Jobs = append(result.Jobs, j) + } + } + log.Printf("Backups: %d/%d Jobs fuer Node %s", len(result.Jobs), len(allJobs), nodeName) + } + } else { + log.Printf("Backups: Jobs laden fehlgeschlagen: %v", err) + } + + if nodeName == "" { + return result + } + + since := fmt.Sprintf("%d", time.Now().AddDate(0, 0, -30).Unix()) + cmd = exec.Command("/usr/bin/pvesh", "get", "/nodes/"+nodeName+"/tasks", + "--output-format", "json", + "--typefilter", "vzdump", + "--limit", "500", + "--since", since, + ) + if out, err := cmd.Output(); err == nil { + var tasks []BackupTask + if err := json.Unmarshal(out, &tasks); err == nil { + result.Tasks = tasks + log.Printf("Backups: %d Tasks (letzte 30 Tage)", len(tasks)) + } else { + log.Printf("Backups: Tasks JSON parse fehler: %v", err) + } + } else { + log.Printf("Backups: Tasks laden fehlgeschlagen: %v", err) + } + + return result +} diff --git a/agent-linux/collector/cpu.go b/agent-linux/collector/cpu.go new file mode 100644 index 0000000..1792cc5 --- /dev/null +++ b/agent-linux/collector/cpu.go @@ -0,0 +1,168 @@ +package collector + +import ( + "os" + "strconv" + "strings" + "time" +) + +type CPUInfo struct { + Model string + Cores int + Threads int + FreqMHz int + UsagePercent float64 +} + +func CollectCPU() CPUInfo { + cpu := CPUInfo{} + + // CPU-Informationen aus /proc/cpuinfo lesen + if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { + lines := strings.Split(string(data), "\n") + cpuCount := 0 + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "model name": + if cpu.Model == "" { // Nur vom ersten CPU nehmen + cpu.Model = value + } + case "cpu MHz": + if cpu.FreqMHz == 0 { // Nur vom ersten CPU nehmen + if freq, err := strconv.ParseFloat(value, 64); err == nil { + cpu.FreqMHz = int(freq) + } + } + case "processor": + cpuCount++ + case "cpu cores": + if cores, err := strconv.Atoi(value); err == nil && cpu.Cores == 0 { + cpu.Cores = cores + } + case "siblings": + if threads, err := strconv.Atoi(value); err == nil && cpu.Threads == 0 { + cpu.Threads = threads + } + } + } + + // Falls cpu cores und siblings nicht vorhanden, nutze processor count + if cpu.Cores == 0 { + cpu.Cores = cpuCount + } + if cpu.Threads == 0 { + cpu.Threads = cpuCount + } + } + + // CPU-Usage berechnen + cpu.UsagePercent = calculateCPUUsage() + + return cpu +} + +// Berechnet die aktuelle CPU-Auslastung über zwei /proc/stat Messungen +func calculateCPUUsage() float64 { + stat1 := readCPUStat() + if stat1 == nil { + return 0.0 + } + + time.Sleep(100 * time.Millisecond) // Kurz warten für zweite Messung + + stat2 := readCPUStat() + if stat2 == nil { + return 0.0 + } + + // Delta berechnen + totalDelta := (stat2.total - stat1.total) + idleDelta := (stat2.idle - stat1.idle) + + if totalDelta <= 0 { + return 0.0 + } + + usage := 100.0 * float64(totalDelta-idleDelta) / float64(totalDelta) + if usage < 0 { + usage = 0 + } + if usage > 100 { + usage = 100 + } + + return usage +} + +type cpuStat struct { + total int64 + idle int64 +} + +// Liest CPU-Statistiken aus /proc/stat +func readCPUStat() *cpuStat { + data, err := os.ReadFile("/proc/stat") + if err != nil { + return nil + } + + lines := strings.Split(string(data), "\n") + if len(lines) == 0 { + return nil + } + + // Erste Zeile sollte "cpu ..." sein + firstLine := strings.TrimSpace(lines[0]) + if !strings.HasPrefix(firstLine, "cpu ") { + return nil + } + + fields := strings.Fields(firstLine) + if len(fields) < 5 { + return nil + } + + // cpu user nice system idle iowait irq softirq ... + var values []int64 + for i := 1; i < len(fields) && i <= 7; i++ { // max 7 Werte lesen + if val, err := strconv.ParseInt(fields[i], 10, 64); err == nil { + values = append(values, val) + } + } + + if len(values) < 4 { + return nil + } + + // Total = Summe aller Werte + total := int64(0) + for _, val := range values { + total += val + } + + // Idle = idle + iowait (falls iowait verfügbar) + idle := values[3] // idle + if len(values) > 4 { + idle += values[4] // + iowait + } + + return &cpuStat{ + total: total, + idle: idle, + } +} \ No newline at end of file diff --git a/agent-linux/collector/disk.go b/agent-linux/collector/disk.go new file mode 100644 index 0000000..15360f2 --- /dev/null +++ b/agent-linux/collector/disk.go @@ -0,0 +1,133 @@ +package collector + +import ( + "os/exec" + "strconv" + "strings" + "syscall" +) + +type DiskInfo struct { + Filesystem string `json:"filesystem"` + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` + MountPoint string `json:"mount_point"` +} + +func CollectDisks() []DiskInfo { + var disks []DiskInfo + + // df -T zur Anzeige aller mounted filesystems + output, err := exec.Command("/usr/bin/df", "-T", "-P").CombinedOutput() + if err != nil { + return disks + } + + lines := strings.Split(string(output), "\n") + if len(lines) < 2 { + return disks + } + + // Erste Zeile ist Header, überspringen + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 7 { + continue + } + + filesystem := fields[0] + fstype := fields[1] + totalKB := fields[2] + usedKB := fields[3] + availKB := fields[4] + mountpoint := fields[6] + + // Überspringe spezielle Filesysteme + if shouldSkipFilesystem(filesystem, fstype, mountpoint) { + continue + } + + // Konvertiere kB zu Bytes + total, _ := strconv.ParseInt(totalKB, 10, 64) + used, _ := strconv.ParseInt(usedKB, 10, 64) + avail, _ := strconv.ParseInt(availKB, 10, 64) + + // Für bessere Genauigkeit: Versuche statvfs syscall + if stat := getStatvfs(mountpoint); stat != nil { + total = int64(stat.Blocks) * int64(stat.Bsize) + avail = int64(stat.Bavail) * int64(stat.Bsize) + used = total - avail + } else { + // Fallback: df-Werte in Bytes konvertieren + total *= 1024 + used *= 1024 + avail *= 1024 + } + + disks = append(disks, DiskInfo{ + Filesystem: filesystem, + TotalBytes: total, + UsedBytes: used, + FreeBytes: avail, + MountPoint: mountpoint, + }) + } + + return disks +} + +// Bestimmt ob ein Filesystem übersprungen werden soll +func shouldSkipFilesystem(filesystem, fstype, mountpoint string) bool { + // Überspringe virtuelle Filesysteme + skipTypes := []string{ + "proc", "sysfs", "devfs", "devtmpfs", "tmpfs", "debugfs", + "securityfs", "cgroup", "cgroup2", "pstore", "bpf", + "tracefs", "configfs", "fusectl", "binfmt_misc", + "autofs", "mqueue", "hugetlbfs", "sunrpc", + } + + for _, skipType := range skipTypes { + if fstype == skipType { + return true + } + } + + // Überspringe spezielle Mount-Points + skipMounts := []string{ + "/dev", "/proc", "/sys", "/run", "/tmp", + "/dev/shm", "/run/lock", "/run/user", + } + + for _, skipMount := range skipMounts { + if strings.HasPrefix(mountpoint, skipMount) { + return true + } + } + + // Überspringe Loop-Devices außer root + if strings.HasPrefix(filesystem, "/dev/loop") && mountpoint != "/" { + return true + } + + // Überspringe Bind-Mounts (heuristic) + if strings.Contains(mountpoint, "/snap/") { + return true + } + + return false +} + +// Holt präzise Filesystem-Statistiken via statvfs syscall +func getStatvfs(path string) *syscall.Statfs_t { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return nil + } + return &stat +} \ No newline at end of file diff --git a/agent-linux/collector/memory.go b/agent-linux/collector/memory.go new file mode 100644 index 0000000..ace532b --- /dev/null +++ b/agent-linux/collector/memory.go @@ -0,0 +1,70 @@ +package collector + +import ( + "os" + "strconv" + "strings" +) + +type MemoryInfo struct { + TotalBytes int64 + UsedBytes int64 + FreeBytes int64 +} + +func CollectMemory() MemoryInfo { + mem := MemoryInfo{} + + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return mem + } + + lines := strings.Split(string(data), "\n") + values := make(map[string]int64) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + key := strings.TrimSuffix(parts[0], ":") + valueStr := parts[1] + + // Wert in kB, konvertiere zu Bytes + if value, err := strconv.ParseInt(valueStr, 10, 64); err == nil { + values[key] = value * 1024 // kB zu Bytes + } + } + + // Basis-Werte + memTotal := values["MemTotal"] + memFree := values["MemFree"] + memAvailable := values["MemAvailable"] + buffers := values["Buffers"] + cached := values["Cached"] + + mem.TotalBytes = memTotal + + // Verfügbarer Speicher: Nutze MemAvailable falls vorhanden, sonst berechne + if memAvailable > 0 { + mem.FreeBytes = memAvailable + } else { + // Fallback: Free + Buffers + Cached (grobe Schätzung) + mem.FreeBytes = memFree + buffers + cached + } + + // Benutzter Speicher = Total - Free + if mem.FreeBytes > mem.TotalBytes { + mem.FreeBytes = mem.TotalBytes + } + mem.UsedBytes = mem.TotalBytes - mem.FreeBytes + + return mem +} \ No newline at end of file diff --git a/agent-linux/collector/network.go b/agent-linux/collector/network.go new file mode 100644 index 0000000..7825411 --- /dev/null +++ b/agent-linux/collector/network.go @@ -0,0 +1,185 @@ +package collector + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +type NetworkInterface struct { + Name string `json:"name"` + Role string `json:"role"` + IP string `json:"ip"` + MAC string `json:"mac"` + Status string `json:"status"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +func CollectNetwork() []NetworkInterface { + var interfaces []NetworkInterface + + // Interface-Statistiken aus /proc/net/dev lesen + stats := readNetDevStats() + + // Interface-Informationen aus /sys/class/net/* sammeln + entries, err := os.ReadDir("/sys/class/net") + if err != nil { + return interfaces + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + ifName := entry.Name() + + // Überspringe loopback + if ifName == "lo" { + continue + } + + iface := NetworkInterface{ + Name: ifName, + Role: determineInterfaceRole(ifName), + } + + // Status aus operstate lesen + if data, err := os.ReadFile("/sys/class/net/" + ifName + "/operstate"); err == nil { + state := strings.TrimSpace(string(data)) + if state == "up" { + iface.Status = "up" + } else { + iface.Status = "down" + } + } + + // MAC-Adresse lesen + if data, err := os.ReadFile("/sys/class/net/" + ifName + "/address"); err == nil { + iface.MAC = strings.TrimSpace(string(data)) + } + + // Statistiken hinzufügen + if stat, exists := stats[ifName]; exists { + iface.RxBytes = stat.rxBytes + iface.TxBytes = stat.txBytes + } + + // IP-Adresse über ip addr bestimmen + iface.IP = getInterfaceIP(ifName) + + interfaces = append(interfaces, iface) + } + + return interfaces +} + +// Bestimmt die Rolle eines Interfaces basierend auf dem Namen +func determineInterfaceRole(ifName string) string { + switch { + case strings.HasPrefix(ifName, "eth"): + return "lan" + case strings.HasPrefix(ifName, "ens"): + return "lan" + case strings.HasPrefix(ifName, "enp"): + return "lan" + case strings.HasPrefix(ifName, "wlan"): + return "wan" + case strings.HasPrefix(ifName, "wlp"): + return "wan" + case strings.HasPrefix(ifName, "ppp"): + return "wan" + case strings.HasPrefix(ifName, "tun"): + return "vpn" + case strings.HasPrefix(ifName, "tap"): + return "vpn" + case strings.HasPrefix(ifName, "br"): + return "bridge" + case strings.HasPrefix(ifName, "docker"): + return "virtual" + case strings.HasPrefix(ifName, "veth"): + return "virtual" + case strings.HasPrefix(ifName, "virbr"): + return "virtual" + default: + return "other" + } +} + +// Liest die erste IPv4-Adresse eines Interfaces +func getInterfaceIP(ifName string) string { + output, err := exec.Command("/usr/sbin/ip", "-4", "addr", "show", ifName).CombinedOutput() + if err != nil { + return "" + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "inet ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + // inet 192.168.1.100/24 ... -> 192.168.1.100 + ipCidr := fields[1] + if parts := strings.SplitN(ipCidr, "/", 2); len(parts) > 0 { + return parts[0] + } + } + } + } + + return "" +} + +// Network-Statistiken +type netDevStat struct { + rxBytes int64 + txBytes int64 +} + +// Liest Interface-Statistiken aus /proc/net/dev +func readNetDevStats() map[string]netDevStat { + stats := make(map[string]netDevStat) + + data, err := os.ReadFile("/proc/net/dev") + if err != nil { + return stats + } + + lines := strings.Split(string(data), "\n") + if len(lines) < 3 { + return stats + } + + // Ersten 2 Zeilen sind Header + for _, line := range lines[2:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Interface: rx_bytes rx_packets ... tx_bytes tx_packets ... + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + ifName := strings.TrimSpace(parts[0]) + values := strings.Fields(strings.TrimSpace(parts[1])) + + if len(values) >= 9 { + // rx_bytes ist das erste Feld, tx_bytes das 9te Feld (0-basiert: Index 8) + rxBytes, _ := strconv.ParseInt(values[0], 10, 64) + txBytes, _ := strconv.ParseInt(values[8], 10, 64) + + stats[ifName] = netDevStat{ + rxBytes: rxBytes, + txBytes: txBytes, + } + } + } + + return stats +} \ No newline at end of file diff --git a/agent-linux/collector/proxmox.go b/agent-linux/collector/proxmox.go new file mode 100644 index 0000000..2304010 --- /dev/null +++ b/agent-linux/collector/proxmox.go @@ -0,0 +1,506 @@ +package collector + +import ( + "encoding/json" + "log" + "os" + "os/exec" + "strconv" + "strings" +) + +type ProxmoxInfo struct { + Available bool `json:"available"` + Version string `json:"version"` + Node string `json:"node"` + VMs []ProxmoxVM `json:"vms,omitempty"` + Containers []ProxmoxContainer `json:"containers,omitempty"` + Storage []ProxmoxStorage `json:"storage,omitempty"` + Cluster *ProxmoxCluster `json:"cluster,omitempty"` + Subscription *ProxmoxSubscription `json:"subscription,omitempty"` + ZFSPools []ZFSPool `json:"zfs_pools,omitempty"` +} + +type ProxmoxVM struct { + VMID int `json:"vmid"` + Name string `json:"name"` + Status string `json:"status"` + Type string `json:"type"` + CPUUsage *float64 `json:"cpu_usage,omitempty"` + MemoryUsed *int64 `json:"memory_used,omitempty"` + MemoryMax *int64 `json:"memory_max,omitempty"` + DiskUsed *int64 `json:"disk_used,omitempty"` + DiskMax *int64 `json:"disk_max,omitempty"` + Uptime *int64 `json:"uptime,omitempty"` + Node string `json:"node"` +} + +type ProxmoxContainer struct { + VMID int `json:"vmid"` + Name string `json:"name"` + Status string `json:"status"` + Type string `json:"type"` + CPUUsage *float64 `json:"cpu_usage,omitempty"` + MemoryUsed *int64 `json:"memory_used,omitempty"` + MemoryMax *int64 `json:"memory_max,omitempty"` + DiskUsed *int64 `json:"disk_used,omitempty"` + DiskMax *int64 `json:"disk_max,omitempty"` + Uptime *int64 `json:"uptime,omitempty"` + Node string `json:"node"` +} + +type ProxmoxStorage struct { + Name string `json:"storage"` + Type string `json:"type"` + Total int64 `json:"total"` + Used int64 `json:"used"` + Available int64 `json:"available"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` + Content string `json:"content"` +} + +type ProxmoxCluster struct { + Name string `json:"name"` + Nodes []ProxmoxClusterNode `json:"nodes"` + Quorum bool `json:"quorum"` +} + +type ProxmoxClusterNode struct { + Name string `json:"name"` + Status string `json:"status"` + Local bool `json:"local"` +} + +type ProxmoxSubscription struct { + Status string `json:"status"` + ProductName string `json:"productname,omitempty"` + Key string `json:"key,omitempty"` + NextDueDate string `json:"next_due_date,omitempty"` +} + +type ZFSPool struct { + Name string `json:"name"` + Size int64 `json:"size"` + Allocated int64 `json:"allocated"` + Free int64 `json:"free"` + Health string `json:"health"` + Fragmentation string `json:"fragmentation,omitempty"` + ScrubStatus string `json:"scrub_status,omitempty"` +} + +func CollectProxmox() *ProxmoxInfo { + proxmox := &ProxmoxInfo{ + Available: false, + } + + // Prüfen ob pvesh verfügbar ist + // Voller Pfad fuer pvesh, da systemd reduzierten PATH hat + if _, err := os.Stat("/usr/bin/pvesh"); err != nil { + log.Printf("Proxmox: pvesh nicht gefunden: %v", err) + return nil // Proxmox nicht verfügbar + } + + log.Printf("Proxmox: pvesh gefunden, sammle Daten...") + proxmox.Available = true + + // Version abrufen + if data := pveshGet("/version"); data != nil { + log.Printf("Proxmox: Version data: %v", data) + if versionData, ok := data.(map[string]interface{}); ok { + if version, ok := versionData["version"].(string); ok { + proxmox.Version = version + } + } + } + + // Node-Name bestimmen + proxmox.Node = getLocalNodeName() + + // VMs sammeln + proxmox.VMs = collectProxmoxVMs() + + // Container sammeln + proxmox.Containers = collectProxmoxContainers() + + // Storage sammeln + proxmox.Storage = collectProxmoxStorage() + + // Cluster-Info sammeln + proxmox.Cluster = collectProxmoxCluster() + + // Subscription-Info sammeln + proxmox.Subscription = collectProxmoxSubscription() + + // ZFS-Pools sammeln + proxmox.ZFSPools = collectZFSPools() + + return proxmox +} + +// pvesh get wrapper +func pveshGet(path string) interface{} { + cmd := exec.Command("/usr/bin/pvesh", "get", path, "--output-format", "json") + output, err := cmd.Output() + if err != nil { + log.Printf("Proxmox: pvesh get %s fehler: %v", path, err) + return nil + } + + log.Printf("Proxmox: pvesh get %s: %d bytes", path, len(output)) + + var result interface{} + if err := json.Unmarshal(output, &result); err != nil { + log.Printf("Proxmox: JSON parse fehler fuer %s: %v", path, err) + return nil + } + + return result +} + +func getLocalNodeName() string { + // Hostname als Node-Name verwenden + if hostname, err := os.Hostname(); err == nil { + return hostname + } + return "localhost" +} + +func collectProxmoxVMs() []ProxmoxVM { + var vms []ProxmoxVM + + nodeName := getLocalNodeName() + data := pveshGet("/nodes/" + nodeName + "/qemu") + if data == nil { + return vms + } + + if vmList, ok := data.([]interface{}); ok { + for _, item := range vmList { + if vmData, ok := item.(map[string]interface{}); ok { + vm := ProxmoxVM{ + Type: "qemu", + Node: nodeName, + } + + if vmid, ok := vmData["vmid"].(float64); ok { + vm.VMID = int(vmid) + } + if name, ok := vmData["name"].(string); ok { + vm.Name = name + } + if status, ok := vmData["status"].(string); ok { + vm.Status = status + } + if cpu, ok := vmData["cpu"].(float64); ok { + vm.CPUUsage = &cpu + } + if mem, ok := vmData["mem"].(float64); ok { + memUsed := int64(mem) + vm.MemoryUsed = &memUsed + } + if maxmem, ok := vmData["maxmem"].(float64); ok { + maxMem := int64(maxmem) + vm.MemoryMax = &maxMem + } + if disk, ok := vmData["disk"].(float64); ok { + diskUsed := int64(disk) + vm.DiskUsed = &diskUsed + } + if maxdisk, ok := vmData["maxdisk"].(float64); ok { + maxDisk := int64(maxdisk) + vm.DiskMax = &maxDisk + } + if uptime, ok := vmData["uptime"].(float64); ok { + uptimeVal := int64(uptime) + vm.Uptime = &uptimeVal + } + + vms = append(vms, vm) + } + } + } + + return vms +} + +func collectProxmoxContainers() []ProxmoxContainer { + var containers []ProxmoxContainer + + nodeName := getLocalNodeName() + data := pveshGet("/nodes/" + nodeName + "/lxc") + if data == nil { + return containers + } + + if containerList, ok := data.([]interface{}); ok { + for _, item := range containerList { + if containerData, ok := item.(map[string]interface{}); ok { + container := ProxmoxContainer{ + Type: "lxc", + Node: nodeName, + } + + if vmid, ok := containerData["vmid"].(float64); ok { + container.VMID = int(vmid) + } + if name, ok := containerData["name"].(string); ok { + container.Name = name + } + if status, ok := containerData["status"].(string); ok { + container.Status = status + } + if cpu, ok := containerData["cpu"].(float64); ok { + container.CPUUsage = &cpu + } + if mem, ok := containerData["mem"].(float64); ok { + memUsed := int64(mem) + container.MemoryUsed = &memUsed + } + if maxmem, ok := containerData["maxmem"].(float64); ok { + maxMem := int64(maxmem) + container.MemoryMax = &maxMem + } + if disk, ok := containerData["disk"].(float64); ok { + diskUsed := int64(disk) + container.DiskUsed = &diskUsed + } + if maxdisk, ok := containerData["maxdisk"].(float64); ok { + maxDisk := int64(maxdisk) + container.DiskMax = &maxDisk + } + if uptime, ok := containerData["uptime"].(float64); ok { + uptimeVal := int64(uptime) + container.Uptime = &uptimeVal + } + + containers = append(containers, container) + } + } + } + + return containers +} + +func collectProxmoxStorage() []ProxmoxStorage { + var storages []ProxmoxStorage + + nodeName := getLocalNodeName() + data := pveshGet("/nodes/" + nodeName + "/storage") + if data == nil { + return storages + } + + if storageList, ok := data.([]interface{}); ok { + for _, item := range storageList { + if storageData, ok := item.(map[string]interface{}); ok { + storage := ProxmoxStorage{} + + if name, ok := storageData["storage"].(string); ok { + storage.Name = name + } + if storageType, ok := storageData["type"].(string); ok { + storage.Type = storageType + } + if total, ok := storageData["total"].(float64); ok { + storage.Total = int64(total) + } + if used, ok := storageData["used"].(float64); ok { + storage.Used = int64(used) + } + if avail, ok := storageData["avail"].(float64); ok { + storage.Available = int64(avail) + } + if enabled, ok := storageData["enabled"].(float64); ok { + storage.Enabled = enabled > 0 + } + if active, ok := storageData["active"].(float64); ok { + storage.Active = active > 0 + } + if content, ok := storageData["content"].(string); ok { + storage.Content = content + } + + storages = append(storages, storage) + } + } + } + + return storages +} + +func collectProxmoxCluster() *ProxmoxCluster { + data := pveshGet("/cluster/status") + if data == nil { + return nil + } + + cluster := &ProxmoxCluster{} + + if clusterData, ok := data.([]interface{}); ok { + for _, item := range clusterData { + if itemData, ok := item.(map[string]interface{}); ok { + if itemType, ok := itemData["type"].(string); ok { + if itemType == "cluster" { + if name, ok := itemData["name"].(string); ok { + cluster.Name = name + } + if quorum, ok := itemData["quorate"].(float64); ok { + cluster.Quorum = quorum > 0 + } + } else if itemType == "node" { + node := ProxmoxClusterNode{} + if name, ok := itemData["name"].(string); ok { + node.Name = name + } + if online, ok := itemData["online"].(float64); ok { + if online > 0 { + node.Status = "online" + } else { + node.Status = "offline" + } + } + if local, ok := itemData["local"].(float64); ok { + node.Local = local > 0 + } + + cluster.Nodes = append(cluster.Nodes, node) + } + } + } + } + } + + if cluster.Name == "" && len(cluster.Nodes) == 0 { + return nil // Kein Cluster + } + + return cluster +} + +func collectProxmoxSubscription() *ProxmoxSubscription { + nodeName := getLocalNodeName() + data := pveshGet("/nodes/" + nodeName + "/subscription") + if data == nil { + return nil + } + + subscription := &ProxmoxSubscription{} + + if subData, ok := data.(map[string]interface{}); ok { + if status, ok := subData["status"].(string); ok { + subscription.Status = status + } + if productName, ok := subData["productname"].(string); ok { + subscription.ProductName = productName + } + if key, ok := subData["key"].(string); ok { + subscription.Key = key + } + if nextDueDate, ok := subData["nextduedate"].(string); ok { + subscription.NextDueDate = nextDueDate + } + } + + return subscription +} + +func collectZFSPools() []ZFSPool { + var pools []ZFSPool + + // zpool list -H -o name,size,alloc,free,health + cmd := exec.Command("/usr/sbin/zpool", "list", "-H", "-o", "name,size,alloc,free,health") + output, err := cmd.Output() + if err != nil { + return pools // ZFS nicht verfügbar oder keine Pools + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + + pool := ZFSPool{ + Name: fields[0], + Health: fields[4], + } + + // Größen parsen (können Suffixe wie K, M, G, T haben) + pool.Size = parseZFSSize(fields[1]) + pool.Allocated = parseZFSSize(fields[2]) + pool.Free = parseZFSSize(fields[3]) + + // Zusätzliche Informationen aus zpool status + pool.ScrubStatus = getZFSPoolScrubStatus(pool.Name) + + pools = append(pools, pool) + } + + return pools +} + +// Parst ZFS-Größen mit Einheiten (z.B. "1.2T" -> Bytes) +func parseZFSSize(sizeStr string) int64 { + if sizeStr == "-" || sizeStr == "" { + return 0 + } + + sizeStr = strings.TrimSpace(sizeStr) + if len(sizeStr) == 0 { + return 0 + } + + // Letzes Zeichen prüfen für Einheit + lastChar := sizeStr[len(sizeStr)-1] + var multiplier int64 = 1 + var numberStr string + + switch lastChar { + case 'K': + multiplier = 1024 + numberStr = sizeStr[:len(sizeStr)-1] + case 'M': + multiplier = 1024 * 1024 + numberStr = sizeStr[:len(sizeStr)-1] + case 'G': + multiplier = 1024 * 1024 * 1024 + numberStr = sizeStr[:len(sizeStr)-1] + case 'T': + multiplier = 1024 * 1024 * 1024 * 1024 + numberStr = sizeStr[:len(sizeStr)-1] + default: + numberStr = sizeStr + } + + if value, err := strconv.ParseFloat(numberStr, 64); err == nil { + return int64(value * float64(multiplier)) + } + + return 0 +} + +// Holt Scrub-Status für einen ZFS-Pool +func getZFSPoolScrubStatus(poolName string) string { + cmd := exec.Command("/usr/sbin/zpool", "status", poolName) + output, err := cmd.Output() + if err != nil { + return "" + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "scrub") { + return line + } + } + + return "" +} \ No newline at end of file diff --git a/agent-linux/collector/services.go b/agent-linux/collector/services.go new file mode 100644 index 0000000..1326827 --- /dev/null +++ b/agent-linux/collector/services.go @@ -0,0 +1,94 @@ +package collector + +import ( + "os/exec" + "strings" +) + +type ServiceInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` +} + +func CollectServices() []ServiceInfo { + var services []ServiceInfo + + // systemctl list-units --type=service --all --no-pager --no-legend + output, err := exec.Command("/usr/bin/systemctl", "list-units", "--type=service", "--all", "--no-pager", "--no-legend").CombinedOutput() + if err != nil { + return services + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Format: UNIT LOAD ACTIVE SUB DESCRIPTION + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + serviceName := fields[0] + load := fields[1] + active := fields[2] + sub := fields[3] + + // Beschreibung ist der Rest ab Feld 4 + description := "" + if len(fields) > 4 { + description = strings.Join(fields[4:], " ") + } + + // Status bestimmen + status := determineServiceStatus(load, active, sub) + + // .service suffix entfernen für bessere Lesbarkeit + displayName := strings.TrimSuffix(serviceName, ".service") + + services = append(services, ServiceInfo{ + Name: displayName, + Description: description, + Status: status, + }) + } + + return services +} + +// Bestimmt den Service-Status basierend auf systemctl Ausgabe +func determineServiceStatus(load, active, sub string) string { + // Priorität auf active state + switch active { + case "active": + // Wenn active, schaue auf sub-state + switch sub { + case "running": + return "running" + case "exited": + return "stopped" // one-shot service, beendet + default: + return "active" + } + case "inactive": + return "stopped" + case "failed": + return "error" + case "activating": + return "starting" + case "deactivating": + return "stopping" + default: + // Falls load state problematisch ist + if load == "not-found" { + return "not-found" + } else if load == "error" { + return "error" + } + return "unknown" + } +} \ No newline at end of file diff --git a/agent-linux/collector/system.go b/agent-linux/collector/system.go new file mode 100644 index 0000000..ae4ea27 --- /dev/null +++ b/agent-linux/collector/system.go @@ -0,0 +1,132 @@ +package collector + +import ( + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// Basis-Systemdaten sammeln + +func CollectHostname() string { + if data, err := os.ReadFile("/etc/hostname"); err == nil { + return strings.TrimSpace(string(data)) + } + + // Fallback + if hostname, err := os.Hostname(); err == nil { + return hostname + } + + return "unknown" +} + +func CollectOSVersion() string { + // Versuche verschiedene Wege die OS-Version zu bestimmen + + // 1. /etc/os-release (Standard für moderne Linux-Distributionen) + if data, err := os.ReadFile("/etc/os-release"); err == nil { + lines := strings.Split(string(data), "\n") + var name, version string + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "NAME=") { + name = strings.Trim(strings.SplitN(line, "=", 2)[1], "\"") + } else if strings.HasPrefix(line, "VERSION=") { + version = strings.Trim(strings.SplitN(line, "=", 2)[1], "\"") + } + } + + if name != "" { + if version != "" { + return name + " " + version + } + return name + } + } + + // 2. /etc/debian_version (Debian/Ubuntu) + if data, err := os.ReadFile("/etc/debian_version"); err == nil { + version := strings.TrimSpace(string(data)) + return "Debian " + version + } + + // 3. /etc/redhat-release (RHEL/CentOS) + if data, err := os.ReadFile("/etc/redhat-release"); err == nil { + return strings.TrimSpace(string(data)) + } + + // 4. uname fallback + if output, err := exec.Command("/usr/bin/uname", "-sr").CombinedOutput(); err == nil { + return strings.TrimSpace(string(output)) + } + + return "Linux (unknown)" +} + +func CollectUptime() int64 { + if data, err := os.ReadFile("/proc/uptime"); err == nil { + parts := strings.Fields(string(data)) + if len(parts) > 0 { + if uptime, err := strconv.ParseFloat(parts[0], 64); err == nil { + return int64(uptime) + } + } + } + + // Fallback: Boot-Zeit berechnen + if stat, err := os.Stat("/proc/1"); err == nil { + bootTime := stat.ModTime() + return int64(time.Since(bootTime).Seconds()) + } + + return 0 +} + +// Hardware-Informationen sammeln +type HardwareInfo struct { + Manufacturer string + Model string + Serial string + BIOSVersion string +} + +func CollectHardware() HardwareInfo { + hw := HardwareInfo{} + + // DMI-Informationen aus /sys/class/dmi/id/ lesen + if data, err := os.ReadFile("/sys/class/dmi/id/sys_vendor"); err == nil { + hw.Manufacturer = strings.TrimSpace(string(data)) + } + + if data, err := os.ReadFile("/sys/class/dmi/id/product_name"); err == nil { + hw.Model = strings.TrimSpace(string(data)) + } + + if data, err := os.ReadFile("/sys/class/dmi/id/product_serial"); err == nil { + hw.Serial = strings.TrimSpace(string(data)) + } + + if data, err := os.ReadFile("/sys/class/dmi/id/bios_version"); err == nil { + hw.BIOSVersion = strings.TrimSpace(string(data)) + } + + // Fallback für Manufacturer + if hw.Manufacturer == "" { + if data, err := os.ReadFile("/sys/class/dmi/id/board_vendor"); err == nil { + hw.Manufacturer = strings.TrimSpace(string(data)) + } + } + + // Fallback für Model + if hw.Model == "" { + if data, err := os.ReadFile("/sys/class/dmi/id/board_name"); err == nil { + hw.Model = strings.TrimSpace(string(data)) + } + } + + return hw +} \ No newline at end of file diff --git a/agent-linux/collector/updates.go b/agent-linux/collector/updates.go new file mode 100644 index 0000000..2b5ec23 --- /dev/null +++ b/agent-linux/collector/updates.go @@ -0,0 +1,162 @@ +package collector + +import ( + "os/exec" + "strings" +) + +type UpdateInfo struct { + UpdateAvailable bool `json:"update_available"` + PendingCount int `json:"pending_count"` + Updates []PendingUpdateInfo `json:"updates"` +} + +type PendingUpdateInfo struct { + Package string `json:"package"` + CurrentVer string `json:"current_version"` + NewVer string `json:"new_version"` +} + +func CollectUpdates() *UpdateInfo { + updates := &UpdateInfo{ + UpdateAvailable: false, + PendingCount: 0, + Updates: []PendingUpdateInfo{}, + } + + // apt list --upgradable 2>/dev/null + cmd := exec.Command("/usr/bin/apt", "list", "--upgradable") + output, err := cmd.CombinedOutput() + if err != nil { + // Fallback: apt-check falls verfügbar + return collectUpdatesAptCheck() + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "WARNING:") || strings.HasPrefix(line, "Listing...") { + continue + } + + // Format: package/suite version [upgradable from: old-version] + // Beispiel: curl/focal-updates 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6] + + if !strings.Contains(line, "[upgradable from:") { + continue + } + + // Package-Name extrahieren (bis zum ersten /) + parts := strings.SplitN(line, "/", 2) + if len(parts) < 2 { + continue + } + packageName := parts[0] + + // Neue Version extrahieren (zwischen / und [upgradable from:) + remaining := parts[1] + beforeUpgradable := strings.Split(remaining, " [upgradable from:") + if len(beforeUpgradable) < 2 { + continue + } + + // Neue Version ist im ersten Teil, aber wir müssen die Architektur entfernen + versionParts := strings.Fields(beforeUpgradable[0]) + if len(versionParts) < 1 { + continue + } + newVersion := versionParts[0] + + // Alte Version extrahieren (zwischen "from:" und "]") + oldVersionPart := beforeUpgradable[1] + oldVersion := strings.TrimSuffix(strings.TrimSpace(oldVersionPart), "]") + + updates.Updates = append(updates.Updates, PendingUpdateInfo{ + Package: packageName, + CurrentVer: oldVersion, + NewVer: newVersion, + }) + } + + updates.PendingCount = len(updates.Updates) + updates.UpdateAvailable = updates.PendingCount > 0 + + return updates +} + +// Fallback-Methode mit apt-check (Ubuntu/Debian) +func collectUpdatesAptCheck() *UpdateInfo { + updates := &UpdateInfo{ + UpdateAvailable: false, + PendingCount: 0, + Updates: []PendingUpdateInfo{}, + } + + // /usr/lib/update-notifier/apt-check + cmd := exec.Command("/usr/lib/update-notifier/apt-check") + output, err := cmd.CombinedOutput() + if err != nil { + // Noch ein Fallback: apt-get -s upgrade + return collectUpdatesAptGetSimulate() + } + + // apt-check gibt "updates;security-updates" auf stderr aus + result := strings.TrimSpace(string(output)) + parts := strings.SplitN(result, ";", 2) + + if len(parts) >= 1 && parts[0] != "0" { + // Packages verfügbar, aber keine Details ohne apt list + updates.UpdateAvailable = true + updates.PendingCount = 1 // Platzhalter + updates.Updates = []PendingUpdateInfo{ + { + Package: "system-updates", + CurrentVer: "various", + NewVer: "available", + }, + } + } + + return updates +} + +// Letzter Fallback mit apt-get simulate +func collectUpdatesAptGetSimulate() *UpdateInfo { + updates := &UpdateInfo{ + UpdateAvailable: false, + PendingCount: 0, + Updates: []PendingUpdateInfo{}, + } + + // apt-get -s upgrade (simulate) + cmd := exec.Command("/usr/bin/apt-get", "-s", "upgrade") + output, err := cmd.CombinedOutput() + if err != nil { + return updates + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // Suche nach "Inst package-name" Zeilen + if strings.HasPrefix(line, "Inst ") { + fields := strings.Fields(line) + if len(fields) >= 3 { + packageName := fields[1] + + // Sehr simple Erkennung, bessere Parsing schwierig ohne apt list + updates.Updates = append(updates.Updates, PendingUpdateInfo{ + Package: packageName, + CurrentVer: "unknown", + NewVer: "available", + }) + } + } + } + + updates.PendingCount = len(updates.Updates) + updates.UpdateAvailable = updates.PendingCount > 0 + + return updates +} \ No newline at end of file diff --git a/agent-linux/config.yaml.example b/agent-linux/config.yaml.example new file mode 100644 index 0000000..cf73d43 --- /dev/null +++ b/agent-linux/config.yaml.example @@ -0,0 +1,8 @@ +# RMM Agent Linux Configuration +# Copy to /etc/rmm/config.yaml and adjust values + +backend_url: "https://your-rmm-backend.example.com" +api_key: "your-api-key-here" +interval_seconds: 60 +agent_name: "linux-server-01" +insecure: true # Set to false in production with valid TLS certificates \ No newline at end of file diff --git a/agent-linux/config/config.go b/agent-linux/config/config.go new file mode 100644 index 0000000..e5a2e4a --- /dev/null +++ b/agent-linux/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + BackendURL string `yaml:"backend_url"` + APIKey string `yaml:"api_key"` + IntervalSeconds int `yaml:"interval_seconds"` + AgentName string `yaml:"agent_name"` + Insecure bool `yaml:"insecure"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + IntervalSeconds: 60, + Insecure: true, + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + + return cfg, nil +} \ No newline at end of file diff --git a/agent-linux/go.mod b/agent-linux/go.mod new file mode 100644 index 0000000..18c1627 --- /dev/null +++ b/agent-linux/go.mod @@ -0,0 +1,8 @@ +module github.com/cynfo/rmm-agent-linux + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/agent-linux/go.sum b/agent-linux/go.sum new file mode 100644 index 0000000..1a390aa --- /dev/null +++ b/agent-linux/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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= diff --git a/agent-linux/install.sh b/agent-linux/install.sh new file mode 100755 index 0000000..64a7535 --- /dev/null +++ b/agent-linux/install.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# RMM Agent Linux Installation Script + +set -e + +echo "==> Installing RMM Agent Linux" + +# Check if running as root +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root" + exit 1 +fi + +# Create directories +mkdir -p /etc/rmm +mkdir -p /usr/local/bin + +# Copy binary +if [ ! -f "rmm-agent-linux" ]; then + echo "Error: rmm-agent-linux binary not found in current directory" + exit 1 +fi + +cp rmm-agent-linux /usr/local/bin/rmm-agent-linux +chmod +x /usr/local/bin/rmm-agent-linux + +# Copy service file +cp rmm-agent-linux.service /etc/systemd/system/ + +# Create example config if it doesn't exist +if [ ! -f "/etc/rmm/config.yaml" ]; then + cp config.yaml.example /etc/rmm/config.yaml + echo "==> Created /etc/rmm/config.yaml - please edit with your backend URL and API key" +fi + +# Reload systemd and enable service +systemctl daemon-reload +systemctl enable rmm-agent-linux + +echo "==> Installation complete!" +echo "" +echo "Next steps:" +echo "1. Edit /etc/rmm/config.yaml with your backend URL and API key" +echo "2. Start the service: systemctl start rmm-agent-linux" +echo "3. Check status: systemctl status rmm-agent-linux" +echo "4. View logs: journalctl -u rmm-agent-linux -f" \ No newline at end of file diff --git a/agent-linux/main.go b/agent-linux/main.go new file mode 100644 index 0000000..db33515 --- /dev/null +++ b/agent-linux/main.go @@ -0,0 +1,203 @@ +package main + +import ( + "flag" + "log" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cynfo/rmm-agent-linux/client" + "github.com/cynfo/rmm-agent-linux/collector" + "github.com/cynfo/rmm-agent-linux/config" + "github.com/cynfo/rmm-agent-linux/ws" +) + +var Version = "dev" + +func main() { + cfgPath := flag.String("config", "/etc/rmm/config.yaml", "Path zur Konfigurationsdatei") + insecure := flag.Bool("insecure", false, "TLS-Zertifikatpruefung deaktivieren") + version := flag.Bool("version", false, "Version anzeigen") + flag.Parse() + + if *version { + log.Printf("RMM Agent Linux v%s", Version) + return + } + + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("Konfiguration laden fehlgeschlagen: %v", err) + } + + if *insecure { + cfg.Insecure = true + } + + log.Printf("RMM Agent Linux v%s startet: %s", Version, cfg.AgentName) + log.Printf("Backend: %s, Intervall: %ds", cfg.BackendURL, cfg.IntervalSeconds) + + c := client.New(cfg.BackendURL, cfg.APIKey, cfg.Insecure) + + // Agent-ID persistent laden oder neu registrieren + idFile := "/etc/rmm/agent_id" + var agentID string + + if data, err := os.ReadFile(idFile); err == nil && len(strings.TrimSpace(string(data))) > 0 { + agentID = strings.TrimSpace(string(data)) + log.Printf("Agent-ID aus Datei geladen: %s", agentID) + } + + // Registrierung mit Retry (sendet bestehende ID mit, falls vorhanden) + backoff := 5 * time.Second + + for { + hostname := collector.CollectHostname() + if hostname == "" { + hostname, _ = os.Hostname() + } + + // Bestimme OS-Version und Platform + osVersion := collector.CollectOSVersion() + platform := "linux" + + regReq := map[string]string{ + "name": cfg.AgentName, + "hostname": hostname, + "ip": getLocalIP(), + "opnsense_version": osVersion, // Verwende OS-Version statt OPNsense + "agent_version": Version, + "platform": platform, + } + if agentID != "" { + regReq["agent_id"] = agentID + } + + newID, regErr := c.Register(regReq) + if regErr != nil { + log.Printf("Registrierung fehlgeschlagen: %v (Retry in %v)", regErr, backoff) + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(5*time.Minute))) + continue + } + agentID = newID + break + } + + // ID persistent speichern - verzeichnis erstellen falls nötig + if err := os.MkdirAll(filepath.Dir(idFile), 0755); err != nil { + log.Printf("WARNUNG: Verzeichnis für Agent-ID konnte nicht erstellt werden: %v", err) + } + if err := os.WriteFile(idFile, []byte(agentID), 0600); err != nil { + log.Printf("WARNUNG: Agent-ID konnte nicht gespeichert werden: %v", err) + } + + log.Printf("Agent registriert mit ID: %s", agentID) + + // WebSocket-Client starten + wsClient := ws.NewClient(cfg.BackendURL, agentID, cfg.APIKey, cfg.Insecure) + if err := wsClient.Start(); err != nil { + log.Printf("WebSocket-Client starten fehlgeschlagen: %v", err) + } + defer wsClient.Stop() + + // Heartbeat-Loop + ticker := time.NewTicker(time.Duration(cfg.IntervalSeconds) * time.Second) + defer ticker.Stop() + + // Ersten Heartbeat sofort senden + sendHeartbeat(c, agentID) + + backoff = 5 * time.Second + for range ticker.C { + if err := sendHeartbeat(c, agentID); err != nil { + log.Printf("Heartbeat fehlgeschlagen: %v", err) + backoff = time.Duration(math.Min(float64(backoff*2), float64(5*time.Minute))) + } else { + backoff = 5 * time.Second + } + } +} + +func sendHeartbeat(c *client.Client, agentID string) error { + log.Println("Sammle Systemdaten...") + + hw := collector.CollectHardware() + cpu := collector.CollectCPU() + mem := collector.CollectMemory() + disks := collector.CollectDisks() + net := collector.CollectNetwork() + svcs := collector.CollectServices() + updates := collector.CollectUpdates() + + // Proxmox-spezifische Daten (optional) + proxmox := collector.CollectProxmox() + + systemData := map[string]interface{}{ + "agent_id": agentID, + "hostname": collector.CollectHostname(), + "opnsense_version": collector.CollectOSVersion(), // Linux/Debian Version + "freebsd_version": "", // Nicht verfügbar auf Linux + "uptime_seconds": collector.CollectUptime(), + "hardware": map[string]interface{}{ + "manufacturer": hw.Manufacturer, + "model": hw.Model, + "serial": hw.Serial, + "bios_version": hw.BIOSVersion, + }, + "cpu": map[string]interface{}{ + "model": cpu.Model, + "cores": cpu.Cores, + "threads": cpu.Threads, + "freq_mhz": cpu.FreqMHz, + "usage_percent": cpu.UsagePercent, + }, + "memory": map[string]interface{}{ + "total_bytes": mem.TotalBytes, + "used_bytes": mem.UsedBytes, + "free_bytes": mem.FreeBytes, + }, + "disks": disks, + "network_interfaces": net, + "services": svcs, + "updates": updates, + } + + // Proxmox-Daten hinzufügen falls verfügbar + if proxmox != nil { + systemData["proxmox"] = proxmox + } + + // Backup-Daten (PVE Jobs + Task-Historie) + if backups := collector.CollectBackups(); backups != nil { + systemData["backups"] = backups + } + + req := map[string]interface{}{ + "agent_id": agentID, + "agent_version": Version, + "system_data": systemData, + } + + if err := c.Heartbeat(req); err != nil { + return err + } + + log.Println("Heartbeat gesendet") + return nil +} + +// getLocalIP versucht die primaere IP-Adresse zu ermitteln +func getLocalIP() string { + // Versuche ueber die Network Interfaces die erste nicht-loopback IP + ifaces := collector.CollectNetwork() + for _, iface := range ifaces { + if iface.IP != "" && iface.Status == "up" && iface.Name != "lo" { + return iface.IP + } + } + return "" +} \ No newline at end of file diff --git a/agent-linux/rmm-agent-linux.service b/agent-linux/rmm-agent-linux.service new file mode 100644 index 0000000..e3f5a7b --- /dev/null +++ b/agent-linux/rmm-agent-linux.service @@ -0,0 +1,24 @@ +[Unit] +Description=RMM Agent Linux +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/etc/rmm +ExecStart=/usr/local/bin/rmm-agent-linux -config /etc/rmm/config.yaml +Restart=always +RestartSec=5 + +# Security settings +NoNewPrivileges=false +PrivateNetwork=false +PrivateTmp=true +ProtectSystem=false +ProtectHome=false + +# Allow access to hardware info and system stats +SupplementaryGroups= + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/agent-linux/ws/client.go b/agent-linux/ws/client.go new file mode 100644 index 0000000..e9b1cfa --- /dev/null +++ b/agent-linux/ws/client.go @@ -0,0 +1,322 @@ +package ws + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type Client struct { + backendURL string + agentID string + apiKey string + insecure bool + + conn *websocket.Conn + connMux sync.RWMutex + writeMux sync.Mutex + + reconnectInterval time.Duration + pingInterval time.Duration + + ctx context.Context + cancel context.CancelFunc + + messageHandler *Handler + tunnelManager *TunnelManager +} + +type Message struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Command string `json:"command,omitempty"` + Event string `json:"event,omitempty"` + Status string `json:"status,omitempty"` + Params map[string]interface{} `json:"params,omitempty"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +func NewClient(backendURL, agentID, apiKey string, insecure bool) *Client { + ctx, cancel := context.WithCancel(context.Background()) + + client := &Client{ + backendURL: backendURL, + agentID: agentID, + apiKey: apiKey, + insecure: insecure, + reconnectInterval: 5 * time.Second, + pingInterval: 30 * time.Second, + ctx: ctx, + cancel: cancel, + } + + client.messageHandler = NewHandler(client) + client.tunnelManager = NewTunnelManager(client) + + return client +} + +func (c *Client) Start() error { + log.Println("WebSocket Client startet...") + + go c.reconnectLoop() + + return nil +} + +func (c *Client) Stop() { + log.Println("WebSocket Client stoppt...") + c.cancel() + + c.connMux.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMux.Unlock() +} + +func (c *Client) reconnectLoop() { + backoff := c.reconnectInterval + maxBackoff := 5 * time.Minute + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + if err := c.connect(); err != nil { + log.Printf("WebSocket-Verbindung fehlgeschlagen: %v", err) + + select { + case <-time.After(backoff): + backoff = time.Duration(float64(backoff) * 1.5) + if backoff > maxBackoff { + backoff = maxBackoff + } + case <-c.ctx.Done(): + return + } + continue + } + + connStart := time.Now() + log.Println("WebSocket-Verbindung hergestellt") + + // Ping/Pong und Message-Handling starten + c.runConnection() + + connDuration := time.Since(connStart) + log.Printf("WebSocket-Verbindung getrennt (Dauer: %v)", connDuration) + + // Alte Verbindung explizit schliessen + c.connMux.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMux.Unlock() + + // Minimum-Delay zwischen Reconnects (verhindert Tight-Loop) + if connDuration < 5*time.Second { + backoff = time.Duration(float64(backoff) * 1.5) + if backoff > maxBackoff { + backoff = maxBackoff + } + log.Printf("Verbindung zu kurz, warte %v vor Reconnect", backoff) + select { + case <-time.After(backoff): + case <-c.ctx.Done(): + return + } + } else { + backoff = c.reconnectInterval + } + } +} + +func (c *Client) connect() error { + // WebSocket-URL aufbauen + u, err := url.Parse(c.backendURL) + if err != nil { + return fmt.Errorf("Backend-URL parsen: %w", err) + } + + if u.Scheme == "https" { + u.Scheme = "wss" + } else { + 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 mit TLS-Config + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.insecure, + }, + } + + conn, _, err := dialer.Dial(u.String(), nil) + if err != nil { + return fmt.Errorf("WebSocket-Dial: %w", err) + } + + c.connMux.Lock() + c.conn = conn + c.connMux.Unlock() + + return nil +} + +func (c *Client) runConnection() { + // Ping-Ticker starten + pingTicker := time.NewTicker(c.pingInterval) + defer pingTicker.Stop() + + // Pong-Handler setzen + c.connMux.RLock() + conn := c.conn + c.connMux.RUnlock() + + if conn == nil { + return + } + + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(c.pingInterval * 2)) + return nil + }) + conn.SetReadDeadline(time.Now().Add(c.pingInterval * 2)) + + // Goroutine für eingehende Nachrichten + msgChan := make(chan Message, 10) + errChan := make(chan error, 1) + + go c.readMessages(conn, msgChan, errChan) + + for { + select { + case <-c.ctx.Done(): + return + + case <-pingTicker.C: + c.writeMux.Lock() + c.connMux.RLock() + conn := c.conn + c.connMux.RUnlock() + if conn != nil { + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + c.writeMux.Unlock() + log.Printf("Ping senden fehlgeschlagen: %v", err) + return + } + } + c.writeMux.Unlock() + + case msg := <-msgChan: + go c.handleMessage(msg) + + case err := <-errChan: + log.Printf("WebSocket-Lesefehler: %v", err) + return + } + } +} + +func (c *Client) readMessages(conn *websocket.Conn, msgChan chan<- Message, errChan chan<- error) { + defer close(msgChan) + + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + errChan <- err + return + } + + if messageType == websocket.BinaryMessage { + if err := c.tunnelManager.ProcessBinaryMessage(data); err != nil { + log.Printf("Binary-Message verarbeiten fehlgeschlagen: %v", err) + } + continue + } + + // JSON-Message parsen + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("JSON-Message parsen fehlgeschlagen: %v", err) + continue + } + + select { + case msgChan <- msg: + case <-c.ctx.Done(): + return + } + } +} + +func (c *Client) handleMessage(msg Message) { + switch msg.Type { + case "command": + c.messageHandler.HandleCommand(msg) + case "event": + // Events vom Backend verarbeiten (falls nötig) + log.Printf("Event erhalten: %s", msg.Event) + default: + log.Printf("Unbekannter Nachrichtentyp: %s", msg.Type) + } +} + +func (c *Client) SendMessage(msg Message) error { + c.connMux.RLock() + conn := c.conn + c.connMux.RUnlock() + + if conn == nil { + return fmt.Errorf("keine WebSocket-Verbindung") + } + + c.writeMux.Lock() + err := conn.WriteJSON(msg) + c.writeMux.Unlock() + return err +} + +func (c *Client) SendBinaryData(data []byte) error { + c.connMux.RLock() + conn := c.conn + c.connMux.RUnlock() + + if conn == nil { + return fmt.Errorf("keine WebSocket-Verbindung") + } + + c.writeMux.Lock() + err := conn.WriteMessage(websocket.BinaryMessage, data) + c.writeMux.Unlock() + return err +} + +func (c *Client) IsConnected() bool { + c.connMux.RLock() + connected := c.conn != nil + c.connMux.RUnlock() + + return connected +} \ No newline at end of file diff --git a/agent-linux/ws/handler.go b/agent-linux/ws/handler.go new file mode 100644 index 0000000..9ff6305 --- /dev/null +++ b/agent-linux/ws/handler.go @@ -0,0 +1,261 @@ +package ws + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "log" + "os" + "os/exec" + "strconv" + "time" +) + +type Handler struct { + client *Client +} + +func NewHandler(client *Client) *Handler { + return &Handler{client: client} +} + +func (h *Handler) HandleCommand(msg Message) { + log.Printf("Command erhalten: %s (ID: %s)", msg.Command, msg.ID) + + var response Message + response.Type = "response" + response.ID = msg.ID + + switch msg.Command { + case "exec": + response = h.handleExec(msg) + case "reboot": + response = h.handleReboot(msg) + case "tunnel_connect": + response = h.handleTunnelConnect(msg) + case "tunnel_disconnect": + h.handleTunnelDisconnect(msg) + return + case "agent_update": + response = h.handleAgentUpdate(msg) + default: + response.Status = "error" + response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command) + } + + if err := h.client.SendMessage(response); err != nil { + log.Printf("Response senden fehlgeschlagen: %v", err) + } +} + +// handleExec fuehrt einen beliebigen Befehl aus +func (h *Handler) handleExec(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + command, ok := msg.Params["command"].(string) + if !ok || command == "" { + response.Status = "error" + response.Error = "Parameter 'command' erforderlich" + return response + } + + timeoutSec := 30 + if t, ok := msg.Params["timeout"]; ok { + if ts, ok := t.(float64); ok { + timeoutSec = int(ts) + } else if ts, ok := t.(string); ok { + if parsed, err := strconv.Atoi(ts); err == nil { + timeoutSec = parsed + } + } + } + + output, err := h.runCommand(command, timeoutSec) + + if err != nil { + response.Status = "error" + response.Error = err.Error() + response.Data = map[string]interface{}{"output": output} + } else { + response.Status = "ok" + response.Data = map[string]interface{}{"output": output} + } + + return response +} + +// handleReboot fuehrt einen Reboot durch +func (h *Handler) handleReboot(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + delaySec := 5 + if d, ok := msg.Params["delay"].(float64); ok && d > 0 { + delaySec = int(d) + } + + log.Printf("Reboot angefordert (Verzoegerung: %ds)", delaySec) + + response.Status = "ok" + response.Data = map[string]interface{}{ + "message": fmt.Sprintf("Reboot in %d Sekunden", delaySec), + } + + go func() { + time.Sleep(time.Duration(delaySec) * time.Second) + log.Println("Reboot wird ausgefuehrt...") + exec.Command("/sbin/shutdown", "-r", "now").Run() + }() + + return response +} + +// handleAgentUpdate aktualisiert den Agent +func (h *Handler) handleAgentUpdate(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + // Binary kommt als Base64 im Data-Feld + data, ok := msg.Data.(map[string]interface{}) + if !ok { + response.Status = "error" + response.Error = "Ungueltige Daten" + return response + } + + binaryB64, _ := data["binary"].(string) + expectedHash, _ := data["hash"].(string) + newVersion, _ := data["version"].(string) + + if binaryB64 == "" || expectedHash == "" { + response.Status = "error" + response.Error = "Binary oder Hash fehlt" + return response + } + + log.Printf("Agent-Update empfangen: Version %s, Hash %s", newVersion, expectedHash[:16]) + + // Base64 dekodieren + binary, err := base64.StdEncoding.DecodeString(binaryB64) + if err != nil { + response.Status = "error" + response.Error = fmt.Sprintf("Base64-Dekodierung fehlgeschlagen: %v", err) + return response + } + + // Hash pruefen + hash := sha256.Sum256(binary) + actualHash := hex.EncodeToString(hash[:]) + if actualHash != expectedHash { + response.Status = "error" + response.Error = fmt.Sprintf("Hash-Mismatch: erwartet %s, bekommen %s", expectedHash[:16], actualHash[:16]) + return response + } + + // Aktuelles Binary finden + binaryPath := "/usr/local/bin/rmm-agent-linux" + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + // Fallback: eigener Pfad + binaryPath, _ = os.Executable() + } + + // Backup vom alten Binary + backupPath := binaryPath + ".old" + if err := os.Rename(binaryPath, backupPath); err != nil { + response.Status = "error" + response.Error = fmt.Sprintf("Backup fehlgeschlagen: %v", err) + return response + } + + // Neues Binary schreiben + if err := os.WriteFile(binaryPath, binary, 0755); err != nil { + // Rollback + os.Rename(backupPath, binaryPath) + response.Status = "error" + response.Error = fmt.Sprintf("Binary schreiben fehlgeschlagen: %v", err) + return response + } + + log.Printf("Neues Binary geschrieben: %s (%d bytes)", binaryPath, len(binary)) + + response.Status = "ok" + response.Data = map[string]interface{}{ + "message": fmt.Sprintf("Update auf %s erfolgreich, Neustart...", newVersion), + "old_version": data["old_version"], + "new_version": newVersion, + } + + // Response senden, dann Neustart + go func() { + time.Sleep(2 * time.Second) + log.Printf("Agent-Neustart nach Update auf %s", newVersion) + // Versuche systemd service restart, fallback zu kill + restart + if err := exec.Command("systemctl", "restart", "rmm-agent-linux").Run(); err != nil { + log.Printf("systemctl restart fehlgeschlagen, versuche manuelle Wiederbelebung: %v", err) + // Als letzter Ausweg: sich selbst ersetzen + os.Exit(0) + } + }() + + return response +} + +// --- Tunnel Handlers --- + +func (h *Handler) handleTunnelConnect(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + sessionID, _ := msg.Params["session_id"].(string) + tunnelID, _ := msg.Params["tunnel_id"].(string) + targetHost, _ := msg.Params["target_host"].(string) + targetPortFloat, _ := msg.Params["target_port"].(float64) + targetPort := int(targetPortFloat) + + if sessionID == "" || targetHost == "" || targetPort <= 0 { + response.Status = "error" + response.Error = "session_id, target_host und target_port erforderlich" + return response + } + + if err := h.client.tunnelManager.ConnectSession(sessionID, tunnelID, targetHost, targetPort); err != nil { + response.Status = "error" + response.Error = fmt.Sprintf("Session-Connect fehlgeschlagen: %v", err) + return response + } + + response.Status = "ok" + response.Data = map[string]interface{}{"session_id": sessionID} + return response +} + +func (h *Handler) handleTunnelDisconnect(msg Message) { + sessionID, _ := msg.Params["session_id"].(string) + if sessionID != "" { + h.client.tunnelManager.DisconnectSession(sessionID) + } +} + +// SendSessionData schickt Tunnel-Daten als Binary-Message zurueck zum Backend +func (h *Handler) SendSessionData(sessionID string, data []byte) error { + idBytes := []byte(sessionID) + if len(idBytes) > 255 { + return fmt.Errorf("session_id zu lang") + } + + message := make([]byte, 1+len(idBytes)+len(data)) + message[0] = byte(len(idBytes)) + copy(message[1:], idBytes) + copy(message[1+len(idBytes):], data) + + return h.client.SendBinaryData(message) +} + +// runCommand fuehrt einen Shell-Befehl mit Timeout aus +func (h *Handler) runCommand(command string, timeoutSec int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) + output, err := cmd.CombinedOutput() + return string(output), err +} \ No newline at end of file diff --git a/agent-linux/ws/tunnel.go b/agent-linux/ws/tunnel.go new file mode 100644 index 0000000..a4219f1 --- /dev/null +++ b/agent-linux/ws/tunnel.go @@ -0,0 +1,178 @@ +package ws + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log" + "net" + "sync" + "time" +) + +// TunnelSession repraesentiert eine einzelne TCP-Verbindung zu einem Ziel +type TunnelSession struct { + ID string + TunnelID string + TargetHost string + TargetPort int + Conn net.Conn + Active bool +} + +// TunnelManager verwaltet alle Sessions auf Agent-Seite +type TunnelManager struct { + client *Client + sessions map[string]*TunnelSession + mutex sync.RWMutex +} + +func NewTunnelManager(client *Client) *TunnelManager { + return &TunnelManager{ + client: client, + sessions: make(map[string]*TunnelSession), + } +} + +// ConnectSession oeffnet eine neue TCP-Verbindung fuer eine Session +func (tm *TunnelManager) ConnectSession(sessionID, tunnelID, targetHost string, targetPort int) error { + target := fmt.Sprintf("%s:%d", targetHost, targetPort) + conn, err := net.DialTimeout("tcp", target, 10*time.Second) + if err != nil { + return fmt.Errorf("Verbindung zu %s fehlgeschlagen: %w", target, err) + } + + session := &TunnelSession{ + ID: sessionID, + TunnelID: tunnelID, + TargetHost: targetHost, + TargetPort: targetPort, + Conn: conn, + Active: true, + } + + tm.mutex.Lock() + tm.sessions[sessionID] = session + tm.mutex.Unlock() + + // Ziel -> Backend Forwarding starten + go tm.forwardFromTarget(session) + + log.Printf("Session %s: Verbindung zu %s hergestellt", sessionID, target) + return nil +} + +// DisconnectSession schliesst eine Session +func (tm *TunnelManager) DisconnectSession(sessionID string) { + tm.mutex.Lock() + session, exists := tm.sessions[sessionID] + if !exists { + tm.mutex.Unlock() + return + } + + session.Active = false + delete(tm.sessions, sessionID) + tm.mutex.Unlock() + + if session.Conn != nil { + session.Conn.Close() + } + + log.Printf("Session %s: geschlossen", sessionID) +} + +// SendDataToSession schreibt Daten in die Ziel-Verbindung einer Session +func (tm *TunnelManager) SendDataToSession(sessionID string, data []byte) error { + tm.mutex.RLock() + session, exists := tm.sessions[sessionID] + tm.mutex.RUnlock() + + if !exists || !session.Active { + return fmt.Errorf("Session %s nicht aktiv", sessionID) + } + + _, err := session.Conn.Write(data) + if err != nil { + tm.DisconnectSession(sessionID) + return fmt.Errorf("Session %s: Write fehlgeschlagen: %w", sessionID, err) + } + + return nil +} + +// forwardFromTarget liest Daten vom Ziel und schickt sie als Binary-Message ans Backend +func (tm *TunnelManager) forwardFromTarget(session *TunnelSession) { + defer func() { + if session.Active { + tm.DisconnectSession(session.ID) + } + log.Printf("Session %s: Forwarding beendet", session.ID) + }() + + buffer := make([]byte, 32768) + + for session.Active { + session.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + n, err := session.Conn.Read(buffer) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + if err == io.EOF { + log.Printf("Session %s: Ziel-Verbindung geschlossen", session.ID) + } else if session.Active { + log.Printf("Session %s: Read-Fehler: %v", session.ID, err) + } + break + } + + if n > 0 { + if err := tm.client.messageHandler.SendSessionData(session.ID, buffer[:n]); err != nil { + log.Printf("Session %s: WebSocket senden fehlgeschlagen: %v", session.ID, err) + break + } + } + } +} + +// ProcessBinaryMessage verarbeitet eingehende Binary-Messages vom Backend (Proxy -> Ziel) +func (tm *TunnelManager) ProcessBinaryMessage(data []byte) error { + sessionID, payload, err := ParseBinaryMessage(data) + if err != nil { + return fmt.Errorf("Binary-Message parsen: %w", err) + } + + return tm.SendDataToSession(sessionID, payload) +} + +// GetActiveSessions gibt die Anzahl aktiver Sessions zurueck +func (tm *TunnelManager) GetActiveSessions() int { + tm.mutex.RLock() + defer tm.mutex.RUnlock() + return len(tm.sessions) +} + +func (tm *TunnelManager) generateID() string { + bytes := make([]byte, 8) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// ParseBinaryMessage parst das Binary-Format: [id_len:1][id][data] +func ParseBinaryMessage(data []byte) (id string, payload []byte, err error) { + if len(data) < 1 { + return "", nil, fmt.Errorf("Daten zu kurz") + } + + idLen := int(data[0]) + if len(data) < 1+idLen { + return "", nil, fmt.Errorf("ID zu kurz") + } + + id = string(data[1 : 1+idLen]) + payload = data[1+idLen:] + return id, payload, nil +} diff --git a/agent/main.go b/agent/main.go index 015ed76..04ae952 100644 --- a/agent/main.go +++ b/agent/main.go @@ -60,6 +60,7 @@ func main() { "ip": getLocalIP(), "opnsense_version": collector.CollectOPNsenseVersion(), "agent_version": Version, + "platform": "freebsd", } if agentID != "" { regReq["agent_id"] = agentID diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 0e4a115..4463504 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -130,6 +130,11 @@ func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) { agentID = generateID() } + platform := req.Platform + if platform == "" { + platform = "freebsd" // Default fuer bestehende OPNsense Agents + } + agent := &models.Agent{ ID: agentID, Name: req.Name, @@ -137,6 +142,7 @@ func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) { IP: req.IP, OPNsenseVersion: req.OPNsenseVersion, AgentVersion: req.AgentVersion, + Platform: platform, RegisteredAt: time.Now().UTC(), } diff --git a/backend/db/postgres.go b/backend/db/postgres.go index a15cd3d..4984f73 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -128,6 +128,7 @@ func (d *Database) migrate() error { // Agents um agent_version und update_requested erweitern 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 platform TEXT NOT NULL DEFAULT 'freebsd'") // Agent-Firmware Tabelle (Multi-Plattform) d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware ( @@ -242,15 +243,16 @@ func (d *Database) Close() error { func (d *Database) RegisterAgent(agent *models.Agent) error { _, err := d.db.Exec(` - INSERT INTO agents (id, name, hostname, ip, opnsense_version, agent_version, registered_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO agents (id, name, hostname, ip, opnsense_version, agent_version, platform, registered_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name, hostname=EXCLUDED.hostname, ip=EXCLUDED.ip, opnsense_version=EXCLUDED.opnsense_version, - agent_version=EXCLUDED.agent_version - `, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.AgentVersion, agent.RegisteredAt.UTC()) + agent_version=EXCLUDED.agent_version, + platform=EXCLUDED.platform + `, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.AgentVersion, agent.Platform, agent.RegisteredAt.UTC()) return err } @@ -365,7 +367,7 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data * } func (d *Database) GetAgents() ([]models.Agent, error) { - rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, agent_version, 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 FROM agents ORDER BY name") if err != nil { return nil, err } @@ -377,7 +379,7 @@ func (d *Database) GetAgents() ([]models.Agent, error) { var lastHB sql.NullTime var custID sql.NullInt64 - if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &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); err != nil { return nil, err } if lastHB.Valid { @@ -443,9 +445,9 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error var custID sql.NullInt64 err := d.db.QueryRow( - "SELECT id, name, hostname, ip, opnsense_version, agent_version, registered_at, last_heartbeat, customer_id, update_requested FROM agents WHERE id = $1", + "SELECT id, name, hostname, ip, opnsense_version, agent_version, platform, registered_at, last_heartbeat, customer_id, update_requested FROM agents WHERE id = $1", id, - ).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested) + ).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.Platform, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested) if err == sql.ErrNoRows { return nil, nil, nil diff --git a/backend/models/agent.go b/backend/models/agent.go index 72c964a..333e608 100644 --- a/backend/models/agent.go +++ b/backend/models/agent.go @@ -10,6 +10,7 @@ type Agent struct { IP string `json:"ip"` OPNsenseVersion string `json:"opnsense_version"` AgentVersion string `json:"agent_version,omitempty"` + Platform string `json:"platform"` RegisteredAt time.Time `json:"registered_at"` LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` CustomerID *int `json:"customer_id,omitempty"` @@ -55,6 +56,7 @@ type RegisterRequest struct { IP string `json:"ip"` OPNsenseVersion string `json:"opnsense_version"` AgentVersion string `json:"agent_version,omitempty"` + Platform string `json:"platform,omitempty"` } // RegisterResponse diff --git a/backend/models/system.go b/backend/models/system.go index bcd29fe..a033950 100644 --- a/backend/models/system.go +++ b/backend/models/system.go @@ -22,6 +22,8 @@ type SystemData struct { Updates *UpdateInfo `json:"updates,omitempty"` CronJobs []CronJob `json:"cron_jobs,omitempty"` License *LicenseInfo `json:"license,omitempty"` + Proxmox map[string]interface{} `json:"proxmox,omitempty"` + Backups map[string]interface{} `json:"backups,omitempty"` } type LicenseInfo struct { diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 6ba88e5..5e8b130 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -169,6 +169,393 @@ Beim ersten Start: - `GetAgentByName`: Sucht Agent nach Name — verwendet fuer Pre-Registration Matching bei Agent-Registrierung +--- + +## API Endpoints + +Base-URL: `https://192.168.85.13:8443/api/v1` + +### Authentifizierung + +Zwei Auth-Methoden (CombinedAuth Middleware): +- **API-Key** (Header `X-API-Key`): Fuer Agents und Automatisierung +- **JWT Bearer** (Header `Authorization: Bearer `): Fuer Frontend + +``` +POST /auth/login Login → JWT Token (8h Ablauf) +GET /auth/me Eigene Benutzerdaten +``` + +```bash +# Login +curl -sk -X POST https://192.168.85.13:8443/api/v1/auth/login \ + -d '{"username":"admin","password":"Start!123"}' +# → {"token":"eyJhbG...","user":{"id":1,"username":"admin"}} +``` + +### Agents + +``` +GET /agents Alle Agents auflisten +GET /agents/{id} Agent + Systemdaten +GET /agents/{id}/system Nur Systemdaten (JSONB) +DELETE /agents/{id} Agent loeschen (inkl. Daten, Events, Backups, Metriken) +POST /agents Firewall pre-registrieren +PUT /agents/{id}/customer Kunden zuordnen +DELETE /agents/{id}/customer Kundenzuordnung entfernen +POST /agent/register Agent-Registrierung (vom Agent selbst) +POST /agent/heartbeat Heartbeat + Systemdaten (vom Agent) +``` + +```bash +# Alle Agents +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents + +# Agent mit Systemdaten +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee + +# Nur Systemdaten +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/system + +# Pre-Registrierung +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents \ + -d '{"name":"OPN.intra.kunde.de","customer_id":1}' + +# Agent loeschen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887 + +# Kunden zuordnen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X PUT \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/customer \ + -d '{"customer_id":1}' +``` + +Agent-Status (automatisch berechnet): + +| Status | Bedingung | +|--------|-----------| +| `online` | Heartbeat < 2 Minuten | +| `stale` | Heartbeat < 5 Minuten | +| `offline` | Heartbeat > 5 Minuten | +| `unknown` | Kein Heartbeat | + +### Agent-Events + +``` +GET /agents/events Alle Events (?limit=N&type=X) +GET /agents/{id}/events Events eines Agents +``` + +Event-Typen: `online`, `offline`, `stale`, `connected`, `disconnected` + +```bash +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/events?limit=10" + +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/events?type=offline" +``` + +### Remote-Befehle (Exec) + +``` +POST /agents/{id}/exec Shell-Befehl ausfuehren +``` + +```bash +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/exec \ + -d '{"command":"uptime","timeout":30}' +# → {"data":{"output":"10:30AM up 45 days, ..."},"status":"ok"} +``` + +### Tunnel-Management + +``` +POST /agents/{id}/tunnel Tunnel oeffnen +GET /agents/{id}/tunnels Aktive Tunnel auflisten +DELETE /agents/{id}/tunnel/{tid} Tunnel schliessen +``` + +```bash +# SSH-Tunnel +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/tunnel \ + -d '{"target_host":"127.0.0.1","target_port":22}' +# → {"tunnel_id":"abc123","proxy_port":10000,...} +# Verbinden: ssh -p 10000 root@192.168.85.13 + +# WebGUI-Tunnel (OPNsense Port 4444) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/tunnel \ + -d '{"target_host":"127.0.0.1","target_port":4444}' +# Im Browser: https://192.168.85.13:10001/ + +# PVE WebGUI-Tunnel (Proxmox Port 8006) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/3f0a83a25d6fcdd8a623e76953559c87/tunnel \ + -d '{"target_host":"127.0.0.1","target_port":8006}' + +# Aktive Tunnel +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/tunnels + +# Tunnel schliessen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/tunnel/abc123 +``` + +Tunnel-Architektur: +- Proxy-Port-Range: 10000-20000 (dynamisch) +- Jede TCP-Verbindung am Proxy bekommt eigene Session +- Agent oeffnet Ziel-Verbindung erst bei erstem Datenpaket (Lazy Connect) +- Binary WebSocket Messages: `[id_len:1][session_id][tcp_payload]` + +### Config-Backups + +``` +POST /agents/{id}/backup Backup ausloesen (via WebSocket) +GET /agents/{id}/backups Alle Backups (?limit=N) +GET /agents/{id}/backups/{id|latest} Backup herunterladen (?format=xml) +DELETE /agents/{id}/backups/{id} Backup loeschen +GET /agents/{id}/backups/diff Diff (?a=ID&b=ID) +``` + +```bash +# Backup ausloesen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/backup + +# Alle Backups +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/backups + +# Neuestes als XML +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/backups/latest?format=xml" \ + -o config-backup.xml + +# Diff +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/backups/diff?a=1&b=3" +``` + +Deduplizierung: Gleicher SHA256-Hash = kein neuer Eintrag. + +### WireGuard Peer-Management + +``` +GET /agents/{id}/wireguard/peers Alle Peers (?server_name=X) +POST /agents/{id}/wireguard/peers Peer anlegen +DELETE /agents/{id}/wireguard/peers/{uuid} Peer loeschen +``` + +Peer-Anlage: Keypair generiert auf Firewall, naechste freie IP automatisch, Endpoint Auto-Detect via WAN-IP. + +```bash +# Peer anlegen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers \ + -d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}' + +# Alle Peers +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers + +# Peer loeschen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers/89c10e2d-148e-11f1-a7af-7c5a1c6c7b73 +``` + +### Kunden (Customers) + +``` +GET /customers Alle Kunden +POST /customers Neuen Kunden anlegen +GET /customers/{id} Einzelnen Kunden abrufen +PUT /customers/{id} Kunden aktualisieren +DELETE /customers/{id} Kunden loeschen +GET /customers/{id}/agents Agents eines Kunden +``` + +```bash +# Kunden anlegen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/customers \ + -d '{"number":"K05001","name":"Beispiel GmbH"}' + +# Kunden aktualisieren +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X PUT \ + https://192.168.85.13:8443/api/v1/customers/1 \ + -d '{"number":"K05001","name":"Beispiel AG"}' + +# Agents eines Kunden +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/customers/1/agents +``` + +### Benutzer (Users) + +``` +GET /users Alle Benutzer +POST /users Neuen Benutzer anlegen +PUT /users/{id}/password Passwort aendern (min. 6 Zeichen) +DELETE /users/{id} Benutzer loeschen +``` + +```bash +# Benutzer anlegen +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/users \ + -d '{"username":"operator","password":"Sicher!123"}' + +# Passwort aendern +curl -sk -H "Authorization: Bearer " -X PUT \ + https://192.168.85.13:8443/api/v1/users/1/password \ + -d '{"password":"NeuesPasswort!123"}' +``` + +### Metriken (TimescaleDB) + +``` +GET /agents/{id}/metrics Rohe Metriken (?metric=X&from=&to=&limit=N) +GET /agents/{id}/metrics/summary Aggregiert (?metric=X&bucket=5+minutes&from=&to=) +``` + +Verfuegbare Metriken: `cpu_usage`, `memory_used_percent`, `memory_used_bytes`, `memory_total_bytes`, `uptime_seconds`, `disk_used_percent`, `disk_used_bytes`, `interface_rx_bytes`, `interface_tx_bytes`, `gateway_rtt_ms`, `gateway_loss_percent` + +```bash +# CPU letzte 24h +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics?metric=cpu_usage" + +# 5-Minuten-Durchschnitte +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics/summary?metric=cpu_usage&bucket=5+minutes" + +# Stuendliche RAM-Zusammenfassung mit Zeitraum +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics/summary?metric=memory_used_percent&bucket=1+hour&from=2026-03-01T00:00:00Z&to=2026-03-01T23:59:59Z" +``` + +### Updates & Reboot + +``` +POST /agents/{id}/update-check Verfuegbare Updates pruefen +POST /agents/{id}/update Normales Update (Core + Packages) +POST /agents/{id}/major-update Major-Upgrade (2-Phasen) +POST /agents/{id}/reboot Reboot +``` + +```bash +# Update-Check +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/update-check + +# Update mit Reboot +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/update \ + -d '{"reboot":true}' + +# Major-Update Phase 1 (Base+Kernel, dann Reboot) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/major-update \ + -d '{"version":"26.1","reboot":true}' + +# Major-Update Phase 2 (nach Reboot: Packages) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/major-update \ + -d '{"version":"26.1","phase":"2"}' + +# Reboot +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/reboot \ + -d '{"delay":5}' +``` + +### Firmware / Agent-Update + +``` +GET /firmware Alle Firmware-Versionen (?platform=X) +POST /firmware/upload?version=X&platform=Y Binary hochladen +GET /firmware/download?platform=Y Binary herunterladen (fuer Updater) +POST /agents/{id}/request-update Update-Flag setzen +DELETE /agents/{id}/request-update Update-Flag zuruecksetzen +POST /agents/request-update-all Alle Agents updaten +POST /agents/{id}/agent-update Legacy: Binary direkt via WebSocket pushen +``` + +```bash +# Firmware-Info +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/firmware + +# Nur FreeBSD-Firmware +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/firmware?platform=freebsd" + +# Firmware hochladen (FreeBSD) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + "https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.3&platform=freebsd" \ + --data-binary @build/rmm-agent + +# Firmware hochladen (Linux) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + "https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.1&platform=linux" \ + --data-binary @build/rmm-agent-linux + +# Update fuer einzelnen Agent anfordern +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/request-update + +# Update-Flag zuruecksetzen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/request-update + +# Alle Agents updaten +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/request-update-all +``` + +Update-Flow: Frontend setzt Flag → Updater (separater Prozess auf Firewall) pollt alle 60s → Download + SHA256-Verify + Rollback bei Fehler → Flag wird geloescht. + +### WebSocket + +``` +GET /agent/ws?agent_id=X&api_key=X WebSocket-Verbindung (Agent) +GET /hub/status Hub-Status +``` + +```bash +# Hub-Status (verbundene Agents, aktive Tunnel) +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/hub/status +``` + +WebSocket Commands (Backend → Agent): + +| Command | Parameter | Beschreibung | +|---------|-----------|-------------| +| `exec` | `command`, `timeout` | Shell-Befehl ausfuehren | +| `backup` | — | Config.xml lesen + senden | +| `update_check` | — | Verfuegbare Updates pruefen | +| `update` | `reboot` | Normales Update | +| `major_update` | `version`, `phase`, `reboot` | Major-Upgrade | +| `reboot` | `delay` | Reboot | +| `tunnel_connect` | `session_id`, `tunnel_id`, `target_host`, `target_port` | TCP-Tunnel oeffnen | +| `tunnel_disconnect` | `session_id` | Tunnel-Session schliessen | +| `wg_add_peer` | `name`, `server_name`, `allowed_ips`, `dns`, `endpoint` | WireGuard Peer anlegen | +| `wg_list_peers` | `server_name` | WireGuard Peers auflisten | +| `wg_delete_peer` | `uuid` | WireGuard Peer loeschen | +| `agent_update` | Binary + Hash (im Data-Feld) | Agent-Binary aktualisieren (Legacy) | + +--- + ## Hinweise - Backend braucht **kein root** — laeuft als normaler User auf Port 8443 diff --git a/frontend/README.md b/frontend/README.md index 18bc70e..23b1bb9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,16 +1,420 @@ -# React + Vite +# RMM Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React + Vite + TailwindCSS Web-Frontend fuer das OPNsense RMM System. -Currently, two official plugins are available: +## Eckdaten -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +| | | +|---|---| +| **Server** | 192.168.85.20 (Debian 13, Nginx) | +| **Tech** | React 18, Vite, TailwindCSS, Recharts, Zustand, lucide-react | +| **Theme** | Dark mit Orange-Akzent, komplett deutsch | +| **Auth** | JWT Login (8h Token) | +| **Default-Login** | `admin` / `Start!123` | +| **Backend** | https://192.168.85.13:8443 | -## React Compiler +## Seiten -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +| Seite | Route | Beschreibung | +|-------|-------|-------------| +| Login | `/login` | JWT Login | +| Dashboard | `/` | Status-Kacheln, Agent-Liste, AgentPanel per Klick | +| Firewalls | `/agents` | Suchbare Tabelle, AgentPanel mit Tabs, Hinzufuegen/Loeschen | +| Tunnel | `/tunnels` | Globale Tunnel-Uebersicht ueber alle Firewalls | +| Firmware | `/firmware` | Multi-Plattform Upload, Agent-Update-Trigger | +| Kunden | `/customers` | CRUD Kundenverwaltung | +| Einstellungen | `/settings` | Benutzerverwaltung, Passwoerter aendern | -## Expanding the ESLint configuration +## AgentPanel Tabs -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +Das Side-Panel oeffnet sich per Klick auf eine Firewall (Dashboard oder Firewalls-Seite): + +| Tab | Inhalt | +|-----|--------| +| Uebersicht | Hardware, OS, Uptime, CPU/RAM, Lizenz (Business Edition) | +| Interfaces | Netzwerk-Interfaces mit IP, MAC, Status, RX/TX | +| Dienste | Laufende Services mit Status-Badge | +| Tunnel | Quick-Buttons (WebGUI, SSH, Custom), aktive Tunnel | +| VPN | WireGuard-Instanzen mit Transfer-Statistiken | +| WireGuard | Peer-Management (CRUD), Client-Config mit Copy-Button | +| Routen | Routing-Tabelle | +| DHCP | Aktive Leases mit Suche (IP, MAC, Hostname) | +| Zertifikate | Karten-Layout mit Ablaufdatum und Fortschrittsbalken | +| Backups | Config-Backup erstellen, Liste, Download als XML | +| Agent | Version, Agent-ID, Update-Button, Remote-Befehle | + +--- + +## API Endpoints + +Alle Aufrufe gehen ueber den API Client (`src/api/client.js`). +Base-URL wird ueber `VITE_API_URL` oder Nginx-Proxy konfiguriert. +Auth: JWT Bearer Token im `Authorization` Header. + +### Authentifizierung + +``` +POST /api/v1/auth/login Login (username, password) → JWT Token +GET /api/v1/auth/me Eigene Benutzerdaten +``` + +**Beispiel:** +```bash +# Login +curl -sk -X POST https://192.168.85.13:8443/api/v1/auth/login \ + -d '{"username":"admin","password":"Start!123"}' +# → {"token":"eyJhbG...","user":{"id":1,"username":"admin"}} + +# Eigene Daten +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/auth/me +``` + +### Agents (Firewalls) + +``` +GET /api/v1/agents Alle Agents auflisten +GET /api/v1/agents/{id} Agent + Systemdaten abrufen +DELETE /api/v1/agents/{id} Agent loeschen +POST /api/v1/agents Firewall pre-registrieren +GET /api/v1/agents/{id}/events Agent-Events (limit=N) +PUT /api/v1/agents/{id}/customer Kunden zuordnen +DELETE /api/v1/agents/{id}/customer Kundenzuordnung entfernen +``` + +**Beispiele:** +```bash +# Alle Agents +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/agents + +# Agent-Details mit Systemdaten +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee + +# Firewall pre-registrieren +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents \ + -d '{"name":"OPN.intra.kunde.de","customer_id":1}' + +# Agent loeschen +curl -sk -H "Authorization: Bearer " -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887 + +# Events (letzte 50) +curl -sk -H "Authorization: Bearer " \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/events?limit=50" + +# Kunden zuordnen +curl -sk -H "Authorization: Bearer " -X PUT \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/customer \ + -d '{"customer_id":1}' +``` + +### Kunden (Customers) + +``` +GET /api/v1/customers Alle Kunden +POST /api/v1/customers Neuen Kunden anlegen +PUT /api/v1/customers/{id} Kunden aktualisieren +DELETE /api/v1/customers/{id} Kunden loeschen +GET /api/v1/customers/{id}/agents Agents eines Kunden +``` + +**Beispiele:** +```bash +# Kunden anlegen +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/customers \ + -d '{"number":"K05001","name":"Beispiel GmbH"}' + +# Kunden aktualisieren +curl -sk -H "Authorization: Bearer " -X PUT \ + https://192.168.85.13:8443/api/v1/customers/1 \ + -d '{"number":"K05001","name":"Beispiel AG"}' + +# Agents eines Kunden +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/customers/1/agents +``` + +### Benutzer (Users) + +``` +GET /api/v1/users Alle Benutzer +POST /api/v1/users Neuen Benutzer anlegen +PUT /api/v1/users/{id}/password Passwort aendern (min. 6 Zeichen) +DELETE /api/v1/users/{id} Benutzer loeschen +``` + +**Beispiele:** +```bash +# Benutzer anlegen +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/users \ + -d '{"username":"operator","password":"Sicher!123"}' + +# Passwort aendern +curl -sk -H "Authorization: Bearer " -X PUT \ + https://192.168.85.13:8443/api/v1/users/1/password \ + -d '{"password":"NeuesPasswort!123"}' +``` + +### Metriken (TimescaleDB) + +``` +GET /api/v1/agents/{id}/metrics Rohe Metriken +GET /api/v1/agents/{id}/metrics/summary Aggregierte Metriken +``` + +Verfuegbare Metriken: `cpu_usage`, `memory_used_percent`, `memory_used_bytes`, `memory_total_bytes`, `uptime_seconds`, `disk_used_percent`, `disk_used_bytes`, `interface_rx_bytes`, `interface_tx_bytes`, `gateway_rtt_ms`, `gateway_loss_percent` + +**Beispiele:** +```bash +# CPU letzte 24h +curl -sk -H "Authorization: Bearer " \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics?metric=cpu_usage" + +# RAM mit Zeitraum +curl -sk -H "Authorization: Bearer " \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics?metric=memory_used_percent&from=2026-03-01T00:00:00Z&to=2026-03-01T23:59:59Z" + +# 5-Minuten-Durchschnitte +curl -sk -H "Authorization: Bearer " \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics/summary?metric=cpu_usage&bucket=5+minutes" +``` + +### Config-Backups + +``` +POST /api/v1/agents/{id}/backup Backup jetzt ausloesen +GET /api/v1/agents/{id}/backups Alle Backups auflisten +GET /api/v1/agents/{id}/backups/{id} Backup herunterladen (?format=xml) +GET /api/v1/agents/{id}/backups/latest Neuestes Backup +DELETE /api/v1/agents/{id}/backups/{id} Backup loeschen +GET /api/v1/agents/{id}/backups/diff Diff (?a=ID&b=ID) +``` + +**Beispiele:** +```bash +# Backup ausloesen +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backup + +# Backups auflisten +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups + +# Als XML herunterladen +curl -sk -H "Authorization: Bearer " \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups/latest?format=xml" \ + -o config-backup.xml + +# Diff zwischen zwei Backups +curl -sk -H "Authorization: Bearer " \ + "https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups/diff?a=1&b=3" +``` + +### Tunnel + +``` +POST /api/v1/agents/{id}/tunnel Tunnel oeffnen +GET /api/v1/agents/{id}/tunnels Aktive Tunnel auflisten +DELETE /api/v1/agents/{id}/tunnel/{tid} Tunnel schliessen +``` + +**Beispiele:** +```bash +# SSH-Tunnel +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnel \ + -d '{"target_host":"127.0.0.1","target_port":22}' +# → {"tunnel_id":"abc123","proxy_port":10000,...} +# Dann: ssh -p 10000 root@192.168.85.13 + +# WebGUI-Tunnel (OPNsense Port 4444) +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnel \ + -d '{"target_host":"127.0.0.1","target_port":4444}' +# Im Browser: https://192.168.85.13:10001/ + +# PVE WebGUI-Tunnel (Proxmox Port 8006) +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/3f0a83a2.../tunnel \ + -d '{"target_host":"127.0.0.1","target_port":8006}' + +# Aktive Tunnel +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnels + +# Tunnel schliessen +curl -sk -H "Authorization: Bearer " -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnel/abc123 +``` + +### WireGuard Peer-Management + +``` +GET /api/v1/agents/{id}/wireguard/peers Alle Peers auflisten +POST /api/v1/agents/{id}/wireguard/peers Neuen Peer anlegen +DELETE /api/v1/agents/{id}/wireguard/peers/{uuid} Peer loeschen +``` + +**Beispiele:** +```bash +# Peer anlegen (Auto: Keypair, naechste freie IP, Endpoint) +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers \ + -d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}' +# → {peer_config: "[Interface]\nPrivateKey=...\n[Peer]\n...", ...} + +# Alle Peers +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers + +# Peer loeschen +curl -sk -H "Authorization: Bearer " -X DELETE \ + https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers/89c10e2d-148e-11f1-a7af-7c5a1c6c7b73 +``` + +### Remote-Befehle (Exec) + +``` +POST /api/v1/agents/{id}/exec Befehl ausfuehren +``` + +**Beispiel:** +```bash +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../exec \ + -d '{"command":"uptime","timeout":30}' +# → {"data":{"output":"10:30AM up 45 days, ..."}, "status":"ok"} +``` + +### Updates + +``` +POST /api/v1/agents/{id}/update-check Verfuegbare Updates pruefen +POST /api/v1/agents/{id}/update Normales Update (Core + Packages) +POST /api/v1/agents/{id}/major-update Major-Upgrade (2-Phasen) +POST /api/v1/agents/{id}/reboot Reboot +``` + +**Beispiele:** +```bash +# Update-Check +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../update-check + +# Update mit Reboot +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../update \ + -d '{"reboot":true}' + +# Major-Update Phase 1 +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../major-update \ + -d '{"version":"26.1","reboot":true}' +``` + +### Firmware / Agent-Update + +``` +GET /api/v1/firmware Alle Firmware-Versionen +POST /api/v1/firmware/upload?version=X&platform=Y Binary hochladen +GET /api/v1/firmware/download?platform=Y Binary herunterladen (Updater) +POST /api/v1/agents/{id}/request-update Update-Flag setzen +DELETE /api/v1/agents/{id}/request-update Update-Flag zuruecksetzen +POST /api/v1/agents/request-update-all Alle Agents updaten +``` + +**Beispiele:** +```bash +# Firmware-Info +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/firmware + +# Firmware hochladen (FreeBSD) +curl -sk -H "Authorization: Bearer " -X POST \ + "https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.3&platform=freebsd" \ + --data-binary @build/rmm-agent + +# Firmware hochladen (Linux) +curl -sk -H "Authorization: Bearer " -X POST \ + "https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.1&platform=linux" \ + --data-binary @build/rmm-agent-linux + +# Update fuer einzelnen Agent anfordern +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a6.../request-update + +# Update fuer alle Agents +curl -sk -H "Authorization: Bearer " -X POST \ + https://192.168.85.13:8443/api/v1/agents/request-update-all +``` + +### Hub-Status + +``` +GET /api/v1/hub/status WebSocket Hub Status +``` + +**Beispiel:** +```bash +curl -sk -H "Authorization: Bearer " \ + https://192.168.85.13:8443/api/v1/hub/status +# → {"connected_agents":3,"active_tunnels":1} +``` + +--- + +## Build & Deploy + +```bash +cd frontend +npm install +npm run build # ~330 KB JS, ~32 KB CSS + +# Deploy +ssh root@192.168.85.20 'rm -rf /var/www/html/assets/*' # Alte Hashes loeschen! +scp -r dist/* root@192.168.85.20:/var/www/html/ +``` + +**Wichtig:** Vor Deploy immer `assets/` loeschen — Vite generiert Hash-basierte Dateinamen, alte Dateien bleiben sonst auf dem Server. + +## Entwicklung + +```bash +cd frontend +npm run dev # Startet Vite Dev-Server mit Proxy zu Backend +``` + +Vite Proxy-Config (`vite.config.js`): `/api/` wird auf `https://192.168.85.13:8443` weitergeleitet. + +## Dateistruktur + +``` +frontend/ +├── src/ +│ ├── App.jsx # Router + ProtectedRoute +│ ├── main.jsx # Entry Point +│ ├── index.css # TailwindCSS + Custom Styles +│ ├── api/client.js # API Client (alle Endpoints) +│ ├── stores/auth.js # Zustand Auth Store (JWT Token) +│ ├── layouts/AppLayout.jsx # Sidebar Navigation + Outlet +│ ├── components/ +│ │ ├── AgentPanel.jsx # Side Panel mit 11 Tabs +│ │ └── StatusBadge.jsx # Online/Offline/Stale Badge +│ └── pages/ +│ ├── Dashboard.jsx # Status-Kacheln + Agent-Liste +│ ├── Agents.jsx # Firewall-Tabelle + Pre-Registration +│ ├── AgentDetail.jsx # Detail-Ansicht (full page, legacy) +│ ├── Tunnels.jsx # Globale Tunnel-Uebersicht +│ ├── Firmware.jsx # Multi-Plattform Firmware + Updates +│ ├── Customers.jsx # Kunden CRUD +│ ├── SettingsPage.jsx # User-Management + Passwort +│ └── Login.jsx # JWT Login +├── vite.config.js # Vite Config mit API Proxy +├── tailwind.config.js +└── package.json +``` diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af1d446..8c1c75b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import Login from './pages/Login' import Dashboard from './pages/Dashboard' import Agents from './pages/Agents' import AgentDetail from './pages/AgentDetail' +import ProxmoxServers from './pages/ProxmoxServers' import Customers from './pages/Customers' import SettingsPage from './pages/SettingsPage' import Tunnels from './pages/Tunnels' @@ -51,6 +52,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index 7f333da..935d320 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -6,16 +6,17 @@ import { Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, } from 'lucide-react' -const tabs = [ +const allTabs = [ { id: 'overview', label: 'Uebersicht' }, { id: 'interfaces', label: 'Interfaces' }, { id: 'services', label: 'Dienste' }, { id: 'tunnels', label: 'Tunnel' }, - { id: 'vpn', label: 'VPN' }, - { id: 'wireguard', label: 'WireGuard' }, + { id: 'vpn', label: 'VPN', platforms: ['freebsd'] }, + { id: 'wireguard', label: 'WireGuard', platforms: ['freebsd'] }, { id: 'routes', label: 'Routen' }, - { id: 'dhcp', label: 'DHCP' }, - { id: 'certs', label: 'Zertifikate' }, + { id: 'dhcp', label: 'DHCP', platforms: ['freebsd'] }, + { id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] }, + { id: 'updates', label: 'Updates', platforms: ['freebsd'] }, { id: 'backups', label: 'Backups' }, { id: 'agent', label: 'Agent' }, ] @@ -44,6 +45,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { const { agent, system_data: sys, status } = data const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null + const platform = agent.platform || 'freebsd' + const tabs = allTabs.filter(t => !t.platforms || t.platforms.includes(platform)) return ( @@ -57,19 +60,19 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
- {agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'} + {agent.hostname} · IP: {agent.ip} · {platform === 'linux' ? 'Linux' : 'OPNsense'} · {sys?.opnsense_version || agent.opnsense_version || '—'}
@@ -119,10 +122,12 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { ) : tab === 'certs' ? ( + ) : tab === 'updates' ? ( + ) : tab === 'backups' ? ( - + platform === 'linux' ? : ) : tab === 'agent' ? ( - + ) : null}
@@ -253,7 +258,7 @@ function OverviewTab({ sys }) { -

Firewall Status

+

System Status

@@ -833,7 +838,7 @@ function WireGuardTab({ agentId, sys }) { } if (noInstance) { - return
Kein ALWAYSON_VPN Tunnel auf dieser Firewall gefunden.
+ return
Kein ALWAYSON_VPN Tunnel auf diesem Geraet gefunden.
} return ( @@ -1145,6 +1150,204 @@ function CertsTab({ sys }) { ) } +function PVEBackupsTab({ sys }) { + const [monthOffset, setMonthOffset] = useState(0) + + const backups = sys?.backups || {} + const jobs = backups.jobs || [] + const tasks = backups.tasks || [] + + // Stats + const totalJobs = jobs.filter(j => j.enabled === 1 || j.enabled === true).length + const okTasks = tasks.filter(t => t.status === 'OK').length + const failedTasks = tasks.filter(t => t.status && t.status !== 'OK').length + const successRate = tasks.length > 0 ? Math.round((okTasks / tasks.length) * 100) : null + + // Naechster Lauf + const nextRuns = jobs.map(j => j.next_run).filter(Boolean).sort() + const nextRun = nextRuns[0] ? new Date(nextRuns[0] * 1000) : null + + // Kalender + const now = new Date() + const calMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1) + const year = calMonth.getFullYear() + const month = calMonth.getMonth() + const daysInMonth = new Date(year, month + 1, 0).getDate() + const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7 // Mo=0 + + // Tasks nach Tag gruppieren + const tasksByDay = {} + tasks.forEach(t => { + const d = new Date(t.starttime * 1000) + if (d.getFullYear() === year && d.getMonth() === month) { + const day = d.getDate() + if (!tasksByDay[day]) tasksByDay[day] = [] + tasksByDay[day].push(t) + } + }) + + // Geplante Tage (aus Schedule-Pattern) + const plannedDays = new Set() + jobs.forEach(j => { + if (!j.enabled) return + const nr = j.next_run + if (nr) { + const d = new Date(nr * 1000) + if (d.getFullYear() === year && d.getMonth() === month) { + plannedDays.add(d.getDate()) + } + } + }) + + const today = new Date() + const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month + + const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] + const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] + + return ( +
+ {/* Stats */} +
+
+
Jobs
+
{totalJobs}
+
+
+
30 Tage
+
{tasks.length}
+
+
+
Erfolgsrate
+
= 90 ? 'text-yellow-400' : 'text-red-400'}`}> + {successRate !== null ? `${successRate}%` : '--'} +
+
+
+
Naechstes
+
+ {nextRun ? nextRun.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ', ' + nextRun.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '--'} +
+
+
+ + {/* Kalender */} +
+
+ +

{monthNames[month]} {year}

+ +
+ +
+ {dayNames.map(d => ( +
{d}
+ ))} + {/* Leere Zellen vor dem 1. */} + {Array.from({ length: firstDayOfWeek }).map((_, i) => ( +
+ ))} + {/* Tage */} + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1 + const dayTasks = tasksByDay[day] || [] + const isToday = isCurrentMonth && day === today.getDate() + const hasOk = dayTasks.some(t => t.status === 'OK') + const hasFailed = dayTasks.some(t => t.status && t.status !== 'OK') + const isPlanned = plannedDays.has(day) + + return ( +
+ {day} + {/* Dot indicators */} +
+ {hasOk && } + {hasFailed && } + {isPlanned && !hasOk && !hasFailed && } +
+
+ ) + })} +
+ + {/* Legende */} +
+ OK + Fehler + Geplant +
+
+ + {/* Konfigurierte Jobs */} +
+

Konfigurierte Jobs

+ {jobs.length === 0 ? ( +
Keine Backup-Jobs konfiguriert
+ ) : ( +
+ {jobs.map((job, i) => { + const nr = job.next_run ? new Date(job.next_run * 1000) : null + const vmids = job.vmid || (job.all ? 'Alle' + (job.exclude ? ` (ohne ${job.exclude})` : '') : '--') + return ( +
+
+
+ + {vmids} +
+
+ {job.schedule} · {job.storage} · {job.mode} + {job.node && · Node: {job.node}} +
+
+ {nr && ( +
+ Naechstes: {nr.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}, {nr.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} +
+ )} +
+ ) + })} +
+ )} +
+ + {/* Letzte Backups */} + {tasks.length > 0 && ( +
+

Letzte Backups ({tasks.length})

+
+ {tasks.slice(0, 30).map((t, i) => { + const start = new Date(t.starttime * 1000) + const duration = t.endtime && t.starttime ? Math.round((t.endtime - t.starttime) / 60) : null + return ( +
+
+ + + {start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} + +
+
+ {duration !== null && {duration} min} + {t.status} +
+
+ ) + })} +
+
+ )} +
+ ) +} + function BackupsTab({ agentId }) { const [backups, setBackups] = useState([]) const [loading, setLoading] = useState(true) @@ -1236,7 +1439,278 @@ function BackupsTab({ agentId }) { ) } -function AgentTab({ agent, status }) { +function UpdatesTab({ agentId }) { + const [checking, setChecking] = useState(false) + const [updating, setUpdating] = useState(false) + const [result, setResult] = useState(null) + const [updateResult, setUpdateResult] = useState(null) + const [reboot, setReboot] = useState(false) + const [majorVer, setMajorVer] = useState('') + const [majorPhase, setMajorPhase] = useState('1') + const [majorReboot, setMajorReboot] = useState(true) + + const checkUpdates = async () => { + setChecking(true) + setResult(null) + try { + const res = await api.checkUpdates(agentId) + setResult(res.data || res) + } catch (err) { + setResult({ error: err.message }) + } finally { + setChecking(false) + } + } + + const runUpdate = async () => { + if (!confirm('Update jetzt ausfuehren?' + (reboot ? ' (mit Reboot)' : ''))) return + setUpdating(true) + setUpdateResult(null) + try { + const res = await api.runUpdate(agentId, reboot) + setUpdateResult(res.data || res) + } catch (err) { + setUpdateResult({ error: err.message }) + } finally { + setUpdating(false) + } + } + + const runMajorUpdate = async () => { + if (!majorVer) return + const phaseText = majorPhase === '1' ? 'Phase 1: Base+Kernel installieren' + (majorReboot ? ' + Reboot' : '') : 'Phase 2: Packages aktualisieren' + if (!confirm(`Major-Update auf ${majorVer}?\n${phaseText}`)) return + setUpdating(true) + setUpdateResult(null) + try { + const res = await api.post(`/api/v1/agents/${agentId}/major-update`, { + version: majorVer, + phase: majorPhase, + reboot: majorReboot + }) + setUpdateResult(res.data || res) + } catch (err) { + setUpdateResult({ error: err.message }) + } finally { + setUpdating(false) + } + } + + const runReboot = async () => { + if (!confirm('Geraet jetzt neu starten?')) return + try { + await api.post(`/api/v1/agents/${agentId}/reboot`, { delay: 5 }) + setUpdateResult({ message: 'Reboot in 5 Sekunden...' }) + } catch (err) { + setUpdateResult({ error: err.message }) + } + } + + const pendingPkgs = result?.pending_packages || [] + + return ( +
+ {/* Update Check */} +
+
+

Update-Check

+ +
+ + {result?.error && ( +
{result.error}
+ )} + + {result && !result.error && ( +
+ {/* Core Update */} +
+
+ + {result.core_update_available ? 'Core-Update verfuegbar' : 'Core ist aktuell'} + +
+ {result.core_update_info && result.core_update_available && ( +
{result.core_update_info}
+ )} + + {/* Reboot Required */} + {result.reboot_required && ( +
+
+ Reboot erforderlich + +
+ )} + + {/* Package Updates */} +
+
0 ? 'bg-orange-400' : 'bg-emerald-400'}`} /> + + {pendingPkgs.length > 0 ? `${pendingPkgs.length} Paket-Updates verfuegbar` : 'Alle Pakete aktuell'} + +
+ + {/* Package List */} + {pendingPkgs.length > 0 && ( +
+ + + + + + + + + + {pendingPkgs.map((pkg, i) => ( + + + + + + ))} + +
PaketAktuellNeu
{pkg.package}{pkg.current_version}{pkg.new_version}
+
+ )} +
+ )} +
+ + {/* Normales Update */} +
+

Normales Update

+

OPNsense Core + Paket-Updates installieren.

+
+ + +
+
+ + {/* Major Update */} +
+

Major-Update

+

+ Upgrade auf neue OPNsense-Version (2 Phasen: Phase 1 = Base+Kernel + Reboot, Phase 2 = Packages). +

+
+ setMajorVer(e.target.value)} + placeholder="z.B. 26.1" + className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 w-28 focus:border-orange-500 focus:outline-none" + /> + + {majorPhase === '1' && ( + + )} +
+ +
+ + {/* Reboot */} +
+

Reboot

+

Geraet manuell neu starten.

+ +
+ + {/* Update-Ergebnis */} + {updateResult && ( +
+

Ergebnis

+ {updateResult.error ? ( +
{updateResult.error}
+ ) : ( +
+ {updateResult.message && ( +
{updateResult.message}
+ )} + {updateResult.steps?.map((step, i) => ( +
+
+
+ {step.step} +
+ {step.output && ( +
{step.output}
+ )} + {step.error &&
{step.error}
} +
+ ))} + {updateResult.reboot_required && ( +
+
+ Reboot erforderlich + {!updateResult.reboot_requested && ( + + )} +
+ )} +
+ )} +
+ )} +
+ ) +} + +function AgentTab({ agent, status, platform }) { const [updating, setUpdating] = useState(false) const [msg, setMsg] = useState('') @@ -1275,13 +1749,17 @@ function AgentTab({ agent, status }) {

Nuetzliche Befehle

- {[ - { label: 'Service Status pruefen:', cmd: 'service rmm_agent status' }, + {(platform === 'linux' ? [ + { label: 'Service Status:', cmd: 'systemctl status rmm-agent' }, + { label: 'Logs anzeigen:', cmd: 'journalctl -u rmm-agent -f' }, + { label: 'Agent neustarten:', cmd: 'systemctl restart rmm-agent' }, + ] : [ + { label: 'Service Status:', cmd: 'service rmm_agent status' }, { label: 'Logs anzeigen:', cmd: 'tail -f /var/log/rmm-agent.log' }, { label: 'Agent neustarten:', cmd: 'service rmm_agent restart' }, { label: 'Updater Status:', cmd: 'service rmm_updater status' }, { label: 'Updater Logs:', cmd: 'tail -f /var/log/rmm-updater.log' }, - ].map((item, i) => ( + ]).map((item, i) => (
{item.label} {item.cmd} @@ -1294,6 +1772,10 @@ function AgentTab({ agent, status }) {

Agent Info

+
+ Plattform: + {platform === 'linux' ? 'Linux' : platform === 'freebsd' ? 'FreeBSD / OPNsense' : platform} +
Agent Version: {agent?.agent_version || '--'} diff --git a/frontend/src/components/ProxmoxPanel.jsx b/frontend/src/components/ProxmoxPanel.jsx new file mode 100644 index 0000000..a732cb4 --- /dev/null +++ b/frontend/src/components/ProxmoxPanel.jsx @@ -0,0 +1,948 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' +import StatusBadge from './StatusBadge' +import { + X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor, + Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check, + MonitorSmartphone, Terminal, Cable, ExternalLink, +} from 'lucide-react' + +const tabs = [ + { id: 'overview', label: 'Uebersicht' }, + { id: 'vms', label: 'Virtuelle Maschinen' }, + { id: 'containers', label: 'Container' }, + { id: 'storage', label: 'Storage' }, + { id: 'zfs', label: 'ZFS' }, + { id: 'tunnel', label: 'Tunnel' }, + { id: 'services', label: 'Dienste' }, + { id: 'updates', label: 'Updates' }, + { id: 'backups', label: 'Backups' }, + { id: 'agent', label: 'Agent' }, +] + +export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) { + const [data, setData] = useState(null) + const [tab, setTab] = useState('overview') + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + setTab('overview') + 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
Laden...
+ if (!data) return
Nicht gefunden
+ + const { agent, system_data: sys, status } = data + const proxmox = sys?.proxmox + const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null + + if (!proxmox?.available) { + return
Keine Proxmox-Daten verfuegbar
+ } + + return ( + + {/* Header */} +
+
+
+
+

{agent.name}

+ +
+ +
+ {agent.hostname} · IP: {agent.ip} · PVE {proxmox.version || '—'} +
+
+
+ + +
+
+ + {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+
+ + {/* Content */} +
+ {!sys ? ( +
Keine Systemdaten
+ ) : tab === 'overview' ? ( + + ) : tab === 'vms' ? ( + + ) : tab === 'containers' ? ( + + ) : tab === 'storage' ? ( + + ) : tab === 'zfs' ? ( + + ) : tab === 'tunnel' ? ( + + ) : tab === 'services' ? ( + + ) : tab === 'updates' ? ( + + ) : tab === 'backups' ? ( + + ) : tab === 'agent' ? ( + + ) : null} +
+
+ ) +} + +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) + } + } + + return ( +
+ {!editing ? ( + + ) : ( + + )} +
+ ) +} + +function Panel({ onClose, children }) { + return ( + <> +
+
+ {children} +
+ + + ) +} + +function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) { + return ( +
+
+ {Icon && } + {label} +
+
{value}
+ {sub &&
{sub}
} + {bar !== undefined && bar !== null && ( +
+
+
+ )} +
+ ) +} + +function StatusCard({ icon: Icon, value, label }) { + return ( +
+
+ {Icon && } +
+
{value}
+
{label}
+
+ ) +} + +function OverviewTab({ sys, proxmox }) { + const rootDisk = sys.disks?.find((d) => d.mount_point === '/') + const diskPct = rootDisk && rootDisk.total_bytes > 0 + ? (rootDisk.used_bytes / rootDisk.total_bytes * 100) : null + const ramPct = sys.memory?.total_bytes > 0 + ? (sys.memory.used_bytes / sys.memory.total_bytes * 100) : null + const cpuPct = sys.cpu?.usage_percent + + const barColor = (v) => v > 90 ? 'bg-red-500' : v > 70 ? 'bg-yellow-500' : v > 50 ? 'bg-orange-500' : 'bg-green-500' + + const vmsRunning = proxmox.vms?.filter(vm => vm.status === 'running').length || 0 + const vmsTotal = proxmox.vms?.length || 0 + const ctRunning = proxmox.containers?.filter(ct => ct.status === 'running').length || 0 + const ctTotal = proxmox.containers?.length || 0 + const storageCount = proxmox.storage?.length || 0 + const zfsCount = proxmox.zfs_pools?.length || 0 + + return ( + <> +

System-Metriken

+
+ + + + +
+ +

Proxmox-Info

+
+
+
+
PVE Version
+
{proxmox.version || '—'}
+
+
+
Node Name
+
{proxmox.node || '—'}
+
+
+
Cluster Name
+
{proxmox.cluster?.name || '—'}
+
+
+
+ + {/* Subscription */} + {proxmox.subscription && ( + <> +

Subscription

+
+
+
+
{proxmox.subscription.productname || 'Proxmox VE'}
+ {proxmox.subscription.key && ( +
{proxmox.subscription.key}
+ )} +
+
+
+ {proxmox.subscription.status || 'Unknown'} +
+ {proxmox.subscription.next_due_date && ( +
bis {proxmox.subscription.next_due_date}
+ )} +
+
+
+ + )} + +

Kurzuebersicht

+
+ + + + +
+ + ) +} + +function VmsTab({ proxmox }) { + const vms = proxmox.vms || [] + + // Sort: running first, then by name + const sortedVms = [...vms].sort((a, b) => { + 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 ( + <> +

Virtuelle Maschinen ({vms.length})

+ {sortedVms.length === 0 ? ( +
Keine VMs gefunden
+ ) : ( +
+ + + + + + + + + + + + + + {sortedVms.map((vm) => ( + + + + + + + + + + ))} + +
VMIDNameStatusCPURAMDiskUptime
{vm.vmid}{vm.name} + + + {vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'} + + {vm.memory_used && vm.memory_max ? + `${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'} + + {vm.disk_used != null && vm.disk_max ? + `${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'} + + {vm.uptime ? formatUptime(vm.uptime) : '—'} +
+
+ )} + + ) +} + +function ContainersTab({ proxmox }) { + const containers = proxmox.containers || [] + + // Sort: running first, then by name + const sortedCt = [...containers].sort((a, b) => { + 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 ( + <> +

Container ({containers.length})

+ {sortedCt.length === 0 ? ( +
Keine Container gefunden
+ ) : ( +
+ + + + + + + + + + + + + {sortedCt.map((ct) => ( + + + + + + + + + ))} + +
VMIDNameStatusCPURAMUptime
{ct.vmid}{ct.name} + + + {ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'} + + {ct.memory_used && ct.memory_max ? + `${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'} + + {ct.uptime ? formatUptime(ct.uptime) : '—'} +
+
+ )} + + ) +} + +function StorageTab({ proxmox }) { + const storage = proxmox.storage || [] + + return ( + <> +

Storage ({storage.length})

+ {storage.length === 0 ? ( +
Keine Storage-Pools gefunden
+ ) : ( +
+ {storage.map((s) => { + const usedPct = s.total && s.total > 0 ? (s.used / s.total * 100) : 0 + const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500' + + return ( +
+
+
+
{s.storage}
+
{s.type} • {s.content}
+
+
+ +
+
+ + {s.total && ( + <> +
+ {formatBytes(s.used)} / {formatBytes(s.total)} + {usedPct.toFixed(1)}% +
+
+
+
+ + )} +
+ ) + })} +
+ )} + + ) +} + +function ZfsTab({ proxmox }) { + const zfsPools = proxmox.zfs_pools || [] + + return ( + <> +

ZFS Pools ({zfsPools.length})

+ {zfsPools.length === 0 ? ( +
Keine ZFS-Pools gefunden
+ ) : ( +
+ {zfsPools.map((pool) => { + const healthColor = pool.health === 'ONLINE' ? 'text-green-400' : + pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400' + const healthBg = pool.health === 'ONLINE' ? 'bg-green-500' : + pool.health === 'DEGRADED' ? 'bg-yellow-500' : 'bg-red-500' + + return ( +
+
+
{pool.name}
+
{pool.health}
+
+ + {(() => { + const usedPct = pool.size > 0 ? (pool.allocated / pool.size * 100) : 0 + const pctColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500' + return ( + <> +
+
+ Groesse: + {formatBytes(pool.size)} +
+
+ Belegt: + {formatBytes(pool.allocated)} ({usedPct.toFixed(1)}%) +
+
+ Frei: + {formatBytes(pool.free)} +
+ {pool.fragmentation && ( +
+ Fragmentierung: + {pool.fragmentation} +
+ )} +
+ +
+
+
+ + {pool.scrub_status && ( +
+
{pool.scrub_status}
+
+ )} + + ) + })()} +
+ ) + })} +
+ )} + + ) +} + +function ServicesTab({ sys }) { + const services = sys.services || [] + + // Sort: running first + const sortedServices = [...services].sort((a, b) => { + const aRunning = a.running || a.status === 'running' + const bRunning = b.running || b.status === 'running' + if (aRunning && !bRunning) return -1 + if (!aRunning && bRunning) return 1 + return a.name.localeCompare(b.name) + }) + + return ( + <> +

Systemd Dienste ({services.length})

+ {sortedServices.length === 0 ? ( +
Keine Dienste gefunden
+ ) : ( +
+ + + + + + + + + {sortedServices.map((svc, i) => { + const running = svc.running || svc.status === 'running' + return ( + + + + + ) + })} + +
NameStatus
{svc.name} + +
+
+ )} + + ) +} + +function UpdatesTab({ sys }) { + const pendingUpdates = sys.pending_updates || [] + + return ( + <> +
+

Updates

+
+ {pendingUpdates.length} Updates verfuegbar +
+
+ + {pendingUpdates.length === 0 ? ( +
Keine Updates verfuegbar
+ ) : ( +
+ + + + + + + + + + {pendingUpdates.map((update, i) => ( + + + + + + ))} + +
PackageCurrent VersionNew Version
{update.package}{update.current_version}{update.new_version}
+
+ )} + + ) +} + +function AgentTab({ agent, status }) { + return ( + <> +

Agent Information

+
+
+
+
Agent Version
+
{agent?.agent_version || '—'}
+
+
+
Status
+ +
+
+
Last Heartbeat
+
+ {agent?.last_heartbeat ? new Date(agent.last_heartbeat).toLocaleString('de-DE') : '—'} +
+
+
+
Agent ID
+
{agent?.id?.substring(0, 12)}...
+
+
+
+ + ) +} + +function TunnelTab({ agentId, agent, webguiPort = 8006 }) { + const [tunnels, setTunnels] = useState([]) + const [loading, setLoading] = useState(true) + const [creating, setCreating] = useState(null) + const [customOpen, setCustomOpen] = useState(false) + const [customHost, setCustomHost] = useState('127.0.0.1') + const [customPort, setCustomPort] = useState('') + const [error, setError] = useState('') + + const backendHost = '192.168.85.13' + + const loadTunnels = () => { + api.getTunnels(agentId) + .then((resp) => { + const list = resp?.tunnels || resp?.data?.tunnels || resp?.data || resp || [] + setTunnels(Array.isArray(list) ? list : []) + }) + .catch(() => setTunnels([])) + .finally(() => setLoading(false)) + } + + useEffect(() => { loadTunnels() }, [agentId]) + useEffect(() => { + if (tunnels.length > 0) { + const iv = setInterval(loadTunnels, 10000) + return () => clearInterval(iv) + } + }, [tunnels.length, agentId]) + + const openTunnel = async (targetHost, targetPort, label) => { + setError('') + setCreating(label) + try { + const resp = await api.openTunnel(agentId, targetHost, targetPort) + const data = resp?.data || resp + loadTunnels() + if (targetPort === webguiPort && data?.proxy_port) { + setTimeout(() => { + window.open(`https://${backendHost}:${data.proxy_port}`, '_blank') + }, 1000) + } + return data + } catch (e) { + setError(e.message) + } finally { + setCreating(null) + } + } + + const closeTunnel = async (tunnelId) => { + try { + await api.closeTunnel(agentId, tunnelId) + setTunnels((prev) => prev.filter((t) => t.id !== tunnelId && t.tunnel_id !== tunnelId)) + } catch (e) { + setError(e.message) + } + } + + const handleCustom = () => { + const port = parseInt(customPort) + if (!port || port < 1 || port > 65535) return + openTunnel(customHost, port, 'custom') + setCustomOpen(false) + setCustomPort('') + } + + return ( +
+
+ Schnellzugriff: + + + +
+ + {customOpen && ( +
+ setCustomHost(e.target.value)} + className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white w-36 focus:outline-none focus:border-orange-500" placeholder="Host" /> + : + setCustomPort(e.target.value)} + className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white w-20 focus:outline-none focus:border-orange-500" placeholder="Port" + onKeyDown={(e) => e.key === 'Enter' && handleCustom()} /> + +
+ )} + + {error &&
{error}
} + + {tunnels.length > 0 && ( + <> +

Aktive Tunnel ({tunnels.length})

+
+ + + + + + + + + + + {tunnels.map((t) => ( + + + + + + + ))} + +
ZielProxy PortZugriff
{t.target_host}:{t.target_port}{t.proxy_port} + + Oeffnen + + + +
+
+ + )} +
+ ) +} + +function BackupsTab({ sys }) { + const backups = sys?.backups || {} + const jobs = backups.jobs || [] + const tasks = backups.tasks || [] + + const totalJobs = jobs.filter(j => j.enabled === 1 || j.enabled === true).length + const okTasks = tasks.filter(t => t.status === 'OK').length + const failedTasks = tasks.filter(t => t.status && t.status !== 'OK').length + const successRate = tasks.length > 0 ? Math.round((okTasks / tasks.length) * 100) : null + + return ( +
+ {/* Stats */} +
+
+
Jobs (aktiv)
+
{totalJobs}
+
+
+
Aufgaben (30d)
+
{tasks.length}
+
+
+
Erfolgreich
+
{okTasks}
+
+
+
Erfolgsrate
+
= 90 ? 'text-yellow-400' : 'text-red-400'}`}> + {successRate !== null ? `${successRate}%` : '--'} +
+
+
+ + {/* Backup Jobs */} +

Backup Jobs

+ {jobs.length === 0 ? ( +
Keine Backup-Jobs konfiguriert
+ ) : ( +
+ + + + + + + + + + + + + {jobs.map((j, i) => ( + + + + + + + + + ))} + +
IDStorageScheduleModusVMs/CTsStatus
{j.id || i}{j.storage || '—'}{j.schedule || '—'}{j.mode || '—'}{j.vmid || j.all ? 'Alle' : '—'} + + {j.enabled ? 'Aktiv' : 'Inaktiv'} + +
+
+ )} + + {/* Task History */} +

Letzte Backup-Aufgaben

+ {tasks.length === 0 ? ( +
Keine Backup-Aufgaben in den letzten 30 Tagen
+ ) : ( +
+ + + + + + + + + + + {tasks.slice(0, 50).map((t, i) => ( + + + + + + + ))} + +
ZeitpunktTypVMIDStatus
+ {t.starttime ? new Date(t.starttime * 1000).toLocaleString('de-DE') : '—'} + {t.type || 'vzdump'}{t.id || '—'} + + {t.status || '—'} + +
+
+ )} + + {failedTasks > 0 && ( +
+ {failedTasks} fehlgeschlagene Backup(s) in den letzten 30 Tagen +
+ )} +
+ ) +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +function formatUptime(seconds) { + 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` +} \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx index 9f7cf05..dda4980 100644 --- a/frontend/src/layouts/AppLayout.jsx +++ b/frontend/src/layouts/AppLayout.jsx @@ -12,11 +12,13 @@ import { X, Cable, Download, + HardDrive, } from 'lucide-react' const navItems = [ { path: '/', label: 'Dashboard', icon: LayoutDashboard }, { path: '/agents', label: 'Firewalls', icon: Server }, + { path: '/proxmox', label: 'Proxmox', icon: HardDrive }, { path: '/tunnels', label: 'Tunnel', icon: Cable }, { path: '/firmware', label: 'Firmware', icon: Download }, { path: '/customers', label: 'Kunden', icon: Users }, diff --git a/frontend/src/pages/Agents.jsx b/frontend/src/pages/Agents.jsx index 33e3245..c6ee58e 100644 --- a/frontend/src/pages/Agents.jsx +++ b/frontend/src/pages/Agents.jsx @@ -63,6 +63,10 @@ export default function Agents() { const detail = agentDetails[a.id] const sys = detail?.system_data const cust = a.customer_id ? customerMap[a.customer_id] : null + + // Nur OPNsense/FreeBSD Agents anzeigen + if (a.platform === 'linux') return null + const rootDisk = sys?.disks?.find((d) => d.mount_point === '/') const diskPct = rootDisk && rootDisk.total_bytes > 0 ? (rootDisk.used_bytes / rootDisk.total_bytes * 100) @@ -85,7 +89,7 @@ export default function Agents() { gateways, sys, } - }) + }).filter(Boolean) // Entferne null Werte }, [agents, agentDetails, customerMap]) const filtered = useMemo(() => { @@ -138,12 +142,12 @@ export default function Agents() { return (
-

Firewalls ({agents.length})

+

Geraete ({agents.length})

@@ -192,7 +196,14 @@ export default function Agents() { {a.customer ? {a.customerNumber} : } - {a.name} + + + + {a.platform === 'linux' ? 'LNX' : 'OPN'} + + {a.name} + + {a.hostname} {a.version || '—'} {a.ip} @@ -227,7 +238,7 @@ export default function Agents() { {filtered.length === 0 && (
- {search ? 'Keine Treffer' : 'Keine Firewalls registriert'} + {search ? 'Keine Treffer' : 'Keine Geraete registriert'}
)}
@@ -243,9 +254,9 @@ export default function Agents() { /> )} - {/* Firewall hinzufuegen Dialog */} + {/* Geraet hinzufuegen Dialog */} {showAddDialog && ( - { @@ -260,7 +271,7 @@ export default function Agents() { ) } -function AddFirewallDialog({ customers, result, onAdd, onClose }) { +function AddDeviceDialog({ customers, result, onAdd, onClose }) { const [name, setName] = useState('') const [custId, setCustId] = useState('') const [error, setError] = useState('') @@ -299,7 +310,7 @@ function AddFirewallDialog({ customers, result, onAdd, onClose }) { # 1. Ins RMM-Verzeichnis wechseln: cd /Users/cynfo/.openclaw/workspace/rmm -# 2. Agent + Updater + Installer auf die Firewall kopieren: +# 2. Agent + Updater + Installer auf das Geraet kopieren: scp build/rmm-agent build/rmm-updater root@:/tmp/ scp opnsense-plugin/install.sh root@:/tmp/ @@ -316,7 +327,7 @@ ssh root@ '/bin/sh /tmp/install.sh'
e.stopPropagation()}>
-

Firewall hinzufuegen

+

Geraet hinzufuegen

@@ -354,13 +365,13 @@ ssh root@ '/bin/sh /tmp/install.sh' disabled={submitting} className="w-full py-2 bg-orange-600 hover:bg-orange-500 disabled:opacity-50 text-white text-sm font-medium rounded transition-colors" > - {submitting ? 'Wird angelegt...' : 'Firewall anlegen'} + {submitting ? 'Wird angelegt...' : 'Geraet anlegen'} ) : ( <>
- Firewall "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...) + Geraet "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index d990404..0a2a6e8 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -2,23 +2,39 @@ import { useEffect, useState } from 'react' import api from '../api/client' import StatusBadge from '../components/StatusBadge' import AgentPanel from '../components/AgentPanel' -import { Server, Users, Activity, AlertTriangle } from 'lucide-react' +import ProxmoxPanel from '../components/ProxmoxPanel' +import { Server, HardDrive, Users, Activity, AlertTriangle } from 'lucide-react' export default function Dashboard() { const [agents, setAgents] = useState([]) const [customers, setCustomers] = useState([]) + const [agentDetails, setAgentDetails] = useState({}) const [loading, setLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) + const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox' const reload = () => { Promise.all([api.getAgents(), api.getCustomers()]) .then(([a, c]) => { setAgents(a || []) setCustomers(c || []) + // Lade Details fuer Typ-Erkennung + ;(a || []).forEach((agent) => { + if (!agentDetails[agent.id]) { + api.getAgent(agent.id).then((d) => { + setAgentDetails((prev) => ({ ...prev, [agent.id]: d })) + }) + } + }) }) .finally(() => setLoading(false)) } + const isProxmox = (agentId) => { + const d = agentDetails[agentId] + return d?.system_data?.proxmox?.available === true + } + useEffect(() => { reload() }, []) @@ -48,8 +64,9 @@ export default function Dashboard() {

Dashboard

{/* Stats */} -
- +
+ !isProxmox(a.id)).length} color="text-blue-400" /> + isProxmox(a.id)).length} color="text-purple-400" /> @@ -67,12 +84,12 @@ export default function Dashboard() { {customer.number} — {customer.name} - {customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'} + {customer.agents.length} {customer.agents.length === 1 ? 'Geraet' : 'Geraete'}
{customer.agents.map((agent) => ( - setSelectedId(agent.id)} selected={selectedId === agent.id} /> + { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} /> ))}
@@ -88,19 +105,27 @@ export default function Dashboard() { {agents .filter((a) => !a.customer_id) .map((agent) => ( - setSelectedId(agent.id)} selected={selectedId === agent.id} /> + { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} /> ))}
)}
- {/* Detail Panel */} - {selectedId && ( + {/* Detail Panel — Geraet-Detail */} + {selectedId && selectedType === 'proxmox' && ( + { setSelectedId(null); setSelectedType(null) }} + onReload={reload} + /> + )} + {selectedId && selectedType !== 'proxmox' && ( setSelectedId(null)} + onClose={() => { setSelectedId(null); setSelectedType(null) }} onReload={reload} /> )} @@ -122,7 +147,7 @@ function StatCard({ icon: Icon, label, value, color }) { ) } -function AgentRow({ agent, onClick, selected }) { +function AgentRow({ agent, isPve, onClick, selected }) { const lastHB = agent.last_heartbeat ? new Date(agent.last_heartbeat).toLocaleString('de-DE') : '—' @@ -135,7 +160,12 @@ function AgentRow({ agent, onClick, selected }) {
-
{agent.name}
+
+ + {agent.platform === 'linux' ? 'LNX' : 'OPN'} + + {agent.name} +
{agent.ip}
diff --git a/frontend/src/pages/Firmware.jsx b/frontend/src/pages/Firmware.jsx index 0516a5d..af5b83a 100644 --- a/frontend/src/pages/Firmware.jsx +++ b/frontend/src/pages/Firmware.jsx @@ -102,14 +102,19 @@ export default function Firmware() { if (loading) return
Laden...
- // Fuer jetzt: alle Agents sind freebsd - const freebsdFw = getFwForPlatform('freebsd') + const getFwForAgent = (agent) => { + const p = agent.platform || 'freebsd' + return getFwForPlatform(p) + } const needsUpdate = (agent) => { - if (!freebsdFw) return false - return agent.agent_version !== freebsdFw.version + const fw = getFwForAgent(agent) + if (!fw) return false + return agent.agent_version !== fw.version } + const hasAnyFirmware = PLATFORMS.some(p => getFwForPlatform(p.id)) + return (

Agent-Firmware

@@ -201,7 +206,7 @@ export default function Firmware() {

Agents

- {freebsdFw && ( + {hasAnyFirmware && ( +
+ +
+
+ + 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-orange-500" + /> +
+
+ + {loading ? ( +
Laden...
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + {filtered.map((a) => ( + setSelectedId(a.id)} + className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`} + > + + + + + + + + + + + + + + ))} + +
HOSTNAMEIP
+ {a.customer ? {a.customerNumber} : } + {a.name}{a.hostname}{a.pveVersion || '—'}{a.ip}{a.uptime ? formatUptime(a.uptime) : '—'} + + + + + + + + + +
+ {filtered.length === 0 && ( +
+ {search ? 'Keine Treffer' : 'Keine Proxmox Server registriert'} +
+ )} +
+ )} + + {/* Detail Panel */} + {selectedId && ( + setSelectedId(null)} + onReload={reload} + /> + )} + + {/* Server hinzufuegen Dialog */} + {showAddDialog && ( + { + const res = await api.preRegisterAgent(name, custId) + setAddResult(res) + reload() + }} + onClose={() => { setShowAddDialog(false); setAddResult(null) }} + /> + )} +
+ ) +} + +function AddServerDialog({ customers, result, onAdd, onClose }) { + const [name, setName] = useState('') + const [custId, setCustId] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [copied, setCopied] = useState(null) + + const BACKEND_URL = 'https://192.168.85.13:8443' + const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9' + + const handleSubmit = async () => { + if (!name.trim()) { setError('Name ist erforderlich'); return } + setError('') + setSubmitting(true) + try { + await onAdd(name.trim(), custId ? parseInt(custId) : null) + } catch (e) { + setError(e.message) + } + setSubmitting(false) + } + + const copyText = (text, id) => { + navigator.clipboard.writeText(text) + setCopied(id) + setTimeout(() => setCopied(null), 2000) + } + + const CopyBtn = ({ text, id }) => ( + + ) + + const installCmd = `# Proxmox/Linux Server Installation: + +# 1. Agent + Updater auf den Server kopieren: +scp build/rmm-agent-linux build/rmm-updater-linux root@:/tmp/ + +# 2. Installation auf dem Server: +ssh root@ 'bash -s' << 'INSTALL' + mkdir -p /usr/local/bin /etc/rmm + + cp /tmp/rmm-agent-linux /usr/local/bin/rmm-agent + cp /tmp/rmm-updater-linux /usr/local/bin/rmm-updater + chmod +x /usr/local/bin/rmm-agent /usr/local/bin/rmm-updater + + # Config erstellen: + cat > /etc/rmm/config.yaml << EOF +backend_url: ${BACKEND_URL} +api_key: ${API_KEY} +agent_name: ${name || ''} +heartbeat_interval: 30 +insecure: true +EOF + + # Agent Service: + cat > /etc/systemd/system/rmm-agent.service << EOF +[Unit] +Description=RMM Agent +After=network.target + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/rmm-agent +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + + # Updater Service: + cat > /etc/systemd/system/rmm-updater.service << EOF +[Unit] +Description=RMM Updater +After=network.target + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/rmm-updater +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +EOF + + systemctl daemon-reload + systemctl enable --now rmm-agent rmm-updater +INSTALL` + + return ( +
+
e.stopPropagation()}> +
+

Server hinzufuegen

+ +
+ +
+ {!result ? ( + <> +
+ + setName(e.target.value)} + placeholder="z.B. PVE-KUNDE.intra.example.net" + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500" + autoFocus + onKeyDown={e => e.key === 'Enter' && handleSubmit()} + /> +
+
+ + +
+ {error &&

{error}

} + + + ) : ( + <> +
+ Server "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...) +
+ +
+

Installationsanleitung

+
+ {installCmd} + +
+
+ +
+
+ Backend URL: + {BACKEND_URL} + +
+
+ API Key: + {API_KEY} + +
+
+ +

+ Der Agent verbindet sich nach der Installation automatisch mit dem Backend. + Hostname und IP werden beim ersten Heartbeat aktualisiert. +

+ + )} +
+
+
+ ) +} + +function VmBadge({ running, total }) { + if (total === 0) return + return ( + + {running} + /{total} + + ) +} + +function MiniBar({ value }) { + if (value === null || value === undefined) return + const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500' + return ( +
+
+
+
+ {value.toFixed(0)}% +
+ ) +} + +function formatUptime(seconds) { + 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` +} \ No newline at end of file diff --git a/frontend/src/pages/Tunnels.jsx b/frontend/src/pages/Tunnels.jsx index 6228262..4110031 100644 --- a/frontend/src/pages/Tunnels.jsx +++ b/frontend/src/pages/Tunnels.jsx @@ -77,7 +77,7 @@ export default function Tunnels() {

Aktive Tunnel

- {allTunnels.length} {allTunnels.length === 1 ? 'Tunnel' : 'Tunnel'} auf {new Set(allTunnels.map((t) => t.agentId)).size} Firewall(s) + {allTunnels.length} {allTunnels.length === 1 ? 'Tunnel' : 'Tunnel'} auf {new Set(allTunnels.map((t) => t.agentId)).size} Geraet(e)
@@ -104,7 +104,7 @@ export default function Tunnels() { 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-orange-500" @@ -114,7 +114,7 @@ export default function Tunnels() { {error &&
{error}
} {loading ? ( -
Lade Tunnel aller Firewalls...
+
Lade Tunnel aller Geraete...
) : (() => { const filtered = search ? allTunnels.filter((t) => { @@ -131,7 +131,7 @@ export default function Tunnels() {
Keine aktiven Tunnel
- Tunnel koennen ueber die Firewall-Detailansicht erstellt werden + Tunnel koennen ueber die Geraete-Detailansicht erstellt werden
) : ( @@ -139,7 +139,7 @@ export default function Tunnels() { - + diff --git a/updater/main.go b/updater/main.go index ca66b3d..6f066ae 100644 --- a/updater/main.go +++ b/updater/main.go @@ -11,13 +11,12 @@ import ( "net/http" "os" "os/exec" - "path/filepath" + "runtime" "time" "gopkg.in/yaml.v3" ) -// Config wird aus der gleichen config.yaml wie der Agent gelesen type Config struct { BackendURL string `yaml:"backend_url"` APIKey string `yaml:"api_key"` @@ -25,26 +24,57 @@ type Config struct { Insecure bool `yaml:"insecure"` } -type HeartbeatResponse struct { - Message string `json:"message"` - UpdateAvailable bool `json:"update_available"` - UpdateVersion string `json:"update_version"` - UpdateHash string `json:"update_hash"` +// Plattform-spezifische Pfade und Befehle +var ( + agentBinaryPath string + configPath string + agentIDPath string + platformName string +) + +func init() { + switch runtime.GOOS { + case "freebsd": + agentBinaryPath = "/usr/local/rmm/rmm-agent" + configPath = "/usr/local/rmm/config.yaml" + agentIDPath = "/usr/local/rmm/agent_id" + platformName = "freebsd" + default: // linux + agentBinaryPath = "/usr/local/bin/rmm-agent" + configPath = "/etc/rmm/config.yaml" + agentIDPath = "/etc/rmm/agent_id" + platformName = "linux" + } +} + +func stopAgent() error { + switch runtime.GOOS { + case "freebsd": + return exec.Command("/usr/sbin/service", "rmm_agent", "stop").Run() + default: + return exec.Command("/usr/bin/systemctl", "stop", "rmm-agent").Run() + } +} + +func startAgent() error { + switch runtime.GOOS { + case "freebsd": + return exec.Command("/usr/sbin/service", "rmm_agent", "start").Run() + default: + return exec.Command("/usr/bin/systemctl", "start", "rmm-agent").Run() + } } const ( - agentBinaryPath = "/usr/local/rmm/rmm-agent" - configPath = "/usr/local/rmm/config.yaml" - agentIDPath = "/usr/local/rmm/agent_id" - checkInterval = 60 * time.Second - logPrefix = "[rmm-updater] " + checkInterval = 60 * time.Second + logPrefix = "[rmm-updater] " ) func main() { log.SetPrefix(logPrefix) log.SetFlags(log.LstdFlags) - log.Println("RMM Updater gestartet") + log.Printf("RMM Updater gestartet (platform=%s, binary=%s)", platformName, agentBinaryPath) cfg, err := loadConfig() if err != nil { @@ -80,9 +110,6 @@ func loadConfig() (*Config, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } - // Default: insecure wenn nicht explizit gesetzt (self-signed certs sind Standard) - // yaml unmarshal setzt false als default fuer bool — wir brauchen true als default - // Workaround: immer insecure wenn Backend self-signed nutzt cfg.Insecure = true return &cfg, nil } @@ -100,8 +127,8 @@ func loadAgentID() (string, error) { } func checkAndUpdate(client *http.Client, cfg *Config, agentID string) { - // Firmware-Info vom Backend holen - url := fmt.Sprintf("%s/api/v1/firmware", cfg.BackendURL) + // Firmware-Info vom Backend holen (plattformspezifisch) + url := fmt.Sprintf("%s/api/v1/firmware?platform=%s", cfg.BackendURL, platformName) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("X-API-Key", cfg.APIKey) @@ -113,7 +140,7 @@ func checkAndUpdate(client *http.Client, cfg *Config, agentID string) { defer resp.Body.Close() if resp.StatusCode != 200 { - return // Keine Firmware vorhanden + return } var info struct { @@ -128,35 +155,34 @@ func checkAndUpdate(client *http.Client, cfg *Config, agentID string) { // Pruefen ob lokale Binary schon die richtige Version hat if localHash, err := hashFile(agentBinaryPath); err == nil && localHash == info.Hash { - return // Schon aktuell + return } - // Heartbeat-Response pruefen: Update angefordert? - hbURL := fmt.Sprintf("%s/api/v1/agents/%s", cfg.BackendURL, agentID) - hbReq, _ := http.NewRequest("GET", hbURL, nil) - hbReq.Header.Set("X-API-Key", cfg.APIKey) + // Update angefordert? + agentURL := fmt.Sprintf("%s/api/v1/agents/%s", cfg.BackendURL, agentID) + agentReq, _ := http.NewRequest("GET", agentURL, nil) + agentReq.Header.Set("X-API-Key", cfg.APIKey) - hbResp, err := client.Do(hbReq) + agentResp, err := client.Do(agentReq) if err != nil { return } - defer hbResp.Body.Close() + defer agentResp.Body.Close() var agentInfo struct { Agent struct { UpdateRequested bool `json:"update_requested"` } `json:"agent"` } - if err := json.NewDecoder(hbResp.Body).Decode(&agentInfo); err != nil { + if err := json.NewDecoder(agentResp.Body).Decode(&agentInfo); err != nil { return } if !agentInfo.Agent.UpdateRequested { - return // Kein Update angefordert + return } - log.Printf("Update verfuegbar: v%s (%d bytes)", info.Version, info.Size) + log.Printf("Update verfuegbar: v%s (%d bytes, platform=%s)", info.Version, info.Size, platformName) - // Binary herunterladen if err := downloadAndApply(client, cfg, agentID, info.Hash); err != nil { log.Printf("Update fehlgeschlagen: %v", err) return @@ -166,8 +192,7 @@ func checkAndUpdate(client *http.Client, cfg *Config, agentID string) { } func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash string) error { - // Download - url := fmt.Sprintf("%s/api/v1/firmware/download", cfg.BackendURL) + url := fmt.Sprintf("%s/api/v1/firmware/download?platform=%s", cfg.BackendURL, platformName) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("X-API-Key", cfg.APIKey) @@ -203,16 +228,15 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st // Agent stoppen log.Println("Agent wird gestoppt...") - exec.Command("/usr/sbin/service", "rmm_agent", "stop").Run() + stopAgent() time.Sleep(2 * time.Second) - // Binary ersetzen + // Binary ersetzen mit Rollback backupPath := agentBinaryPath + ".bak" if _, err := os.Stat(agentBinaryPath); err == nil { os.Rename(agentBinaryPath, backupPath) } if err := os.Rename(tmpPath, agentBinaryPath); err != nil { - // Rollback os.Rename(backupPath, agentBinaryPath) return fmt.Errorf("binary ersetzen fehlgeschlagen: %v", err) } @@ -220,10 +244,10 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st // Agent starten log.Println("Agent wird gestartet...") - if err := exec.Command("/usr/sbin/service", "rmm_agent", "start").Run(); err != nil { + if err := startAgent(); err != nil { log.Printf("WARNUNG: Agent-Start fehlgeschlagen: %v — versuche Rollback", err) os.Rename(backupPath, agentBinaryPath) - exec.Command("/usr/sbin/service", "rmm_agent", "start").Run() + startAgent() return fmt.Errorf("agent start fehlgeschlagen") } @@ -247,8 +271,3 @@ func hashFile(path string) (string, error) { hash := sha256.Sum256(data) return hex.EncodeToString(hash[:]), nil } - -// resolveDir gibt das Verzeichnis eines Pfads zurueck -func resolveDir(path string) string { - return filepath.Dir(path) -}
FirewallGeraet Ziel Lokaler Zugang Status