Linux/Proxmox Agent + Plattform-Updater + Proxmox Frontend
- agent-linux/: Kompletter Linux/Proxmox RMM Agent - Collectors: CPU, Memory, Disk, Network, Services, Updates, Proxmox (VMs, Container, Storage, ZFS), Backups - Backup Collector: Jobs nach Node gefiltert (Cluster-aware) - WebSocket: Tunnel-Support, writeMux fuer stabile Verbindungen - Systemd Service Template - updater/: Plattform-unabhaengiger Updater (FreeBSD + Linux) - runtime.GOOS erkennt Plattform automatisch - FreeBSD: /usr/local/rmm/, service rmm_agent - Linux: /usr/local/bin/, /etc/rmm/, systemctl rmm-agent - Firmware-Download mit ?platform= Parameter - frontend/: Proxmox-Bereich - ProxmoxServers.jsx: Eigene Seite fuer PVE Server mit VM/CT Counts - ProxmoxPanel.jsx: Detail-Panel mit Uebersicht, VMs, Container, Storage, ZFS, Tunnel, Dienste, Updates, Backups - Agents.jsx: Filtert Linux-Agents raus (nur OPNsense) - Sidebar: Proxmox Navigationspunkt - Installationsanleitung mit rmm-agent-linux + rmm-updater-linux - backend: Platform-Feld fuer Agents, Firmware multi-platform
This commit is contained in:
parent
c114df215a
commit
7f9fbd257f
10
Makefile
10
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
|
BACKEND_BIN = build/rmm-backend
|
||||||
AGENT_BIN = build/rmm-agent
|
AGENT_BIN = build/rmm-agent
|
||||||
|
AGENT_LINUX_BIN = build/rmm-agent-linux
|
||||||
UPDATER_BIN = build/rmm-updater
|
UPDATER_BIN = build/rmm-updater
|
||||||
|
|
||||||
BACKEND_HOST = 192.168.85.13
|
BACKEND_HOST = 192.168.85.13
|
||||||
AGENT_HOST = 192.168.85.33
|
AGENT_HOST = 192.168.85.33
|
||||||
SSH_USER = root
|
SSH_USER = root
|
||||||
|
|
||||||
all: backend agent updater
|
all: backend agent agent-linux updater
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
@echo "==> Building Backend (linux/amd64)..."
|
@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) .
|
cd agent && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_BIN) .
|
||||||
@echo "==> $(AGENT_BIN) erstellt"
|
@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:
|
updater:
|
||||||
@echo "==> Building Updater (freebsd/amd64)..."
|
@echo "==> Building Updater (freebsd/amd64)..."
|
||||||
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_BIN) .
|
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_BIN) .
|
||||||
|
|||||||
142
agent-linux/README.md
Normal file
142
agent-linux/README.md
Normal file
@ -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
|
||||||
|
```
|
||||||
95
agent-linux/client/client.go
Normal file
95
agent-linux/client/client.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
89
agent-linux/collector/backups.go
Normal file
89
agent-linux/collector/backups.go
Normal file
@ -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
|
||||||
|
}
|
||||||
168
agent-linux/collector/cpu.go
Normal file
168
agent-linux/collector/cpu.go
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
133
agent-linux/collector/disk.go
Normal file
133
agent-linux/collector/disk.go
Normal file
@ -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
|
||||||
|
}
|
||||||
70
agent-linux/collector/memory.go
Normal file
70
agent-linux/collector/memory.go
Normal file
@ -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
|
||||||
|
}
|
||||||
185
agent-linux/collector/network.go
Normal file
185
agent-linux/collector/network.go
Normal file
@ -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
|
||||||
|
}
|
||||||
506
agent-linux/collector/proxmox.go
Normal file
506
agent-linux/collector/proxmox.go
Normal file
@ -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 ""
|
||||||
|
}
|
||||||
94
agent-linux/collector/services.go
Normal file
94
agent-linux/collector/services.go
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
132
agent-linux/collector/system.go
Normal file
132
agent-linux/collector/system.go
Normal file
@ -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
|
||||||
|
}
|
||||||
162
agent-linux/collector/updates.go
Normal file
162
agent-linux/collector/updates.go
Normal file
@ -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
|
||||||
|
}
|
||||||
8
agent-linux/config.yaml.example
Normal file
8
agent-linux/config.yaml.example
Normal file
@ -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
|
||||||
33
agent-linux/config/config.go
Normal file
33
agent-linux/config/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
8
agent-linux/go.mod
Normal file
8
agent-linux/go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
6
agent-linux/go.sum
Normal file
6
agent-linux/go.sum
Normal file
@ -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=
|
||||||
46
agent-linux/install.sh
Executable file
46
agent-linux/install.sh
Executable file
@ -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"
|
||||||
203
agent-linux/main.go
Normal file
203
agent-linux/main.go
Normal file
@ -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 ""
|
||||||
|
}
|
||||||
24
agent-linux/rmm-agent-linux.service
Normal file
24
agent-linux/rmm-agent-linux.service
Normal file
@ -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
|
||||||
322
agent-linux/ws/client.go
Normal file
322
agent-linux/ws/client.go
Normal file
@ -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
|
||||||
|
}
|
||||||
261
agent-linux/ws/handler.go
Normal file
261
agent-linux/ws/handler.go
Normal file
@ -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
|
||||||
|
}
|
||||||
178
agent-linux/ws/tunnel.go
Normal file
178
agent-linux/ws/tunnel.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -60,6 +60,7 @@ func main() {
|
|||||||
"ip": getLocalIP(),
|
"ip": getLocalIP(),
|
||||||
"opnsense_version": collector.CollectOPNsenseVersion(),
|
"opnsense_version": collector.CollectOPNsenseVersion(),
|
||||||
"agent_version": Version,
|
"agent_version": Version,
|
||||||
|
"platform": "freebsd",
|
||||||
}
|
}
|
||||||
if agentID != "" {
|
if agentID != "" {
|
||||||
regReq["agent_id"] = agentID
|
regReq["agent_id"] = agentID
|
||||||
|
|||||||
@ -130,6 +130,11 @@ func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
agentID = generateID()
|
agentID = generateID()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
platform := req.Platform
|
||||||
|
if platform == "" {
|
||||||
|
platform = "freebsd" // Default fuer bestehende OPNsense Agents
|
||||||
|
}
|
||||||
|
|
||||||
agent := &models.Agent{
|
agent := &models.Agent{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@ -137,6 +142,7 @@ func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
IP: req.IP,
|
IP: req.IP,
|
||||||
OPNsenseVersion: req.OPNsenseVersion,
|
OPNsenseVersion: req.OPNsenseVersion,
|
||||||
AgentVersion: req.AgentVersion,
|
AgentVersion: req.AgentVersion,
|
||||||
|
Platform: platform,
|
||||||
RegisteredAt: time.Now().UTC(),
|
RegisteredAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,6 +128,7 @@ func (d *Database) migrate() error {
|
|||||||
// Agents um agent_version und update_requested erweitern
|
// 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 agent_version TEXT NOT NULL DEFAULT ''")
|
||||||
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE")
|
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
|
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'freebsd'")
|
||||||
|
|
||||||
// Agent-Firmware Tabelle (Multi-Plattform)
|
// Agent-Firmware Tabelle (Multi-Plattform)
|
||||||
d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware (
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware (
|
||||||
@ -242,15 +243,16 @@ func (d *Database) Close() error {
|
|||||||
|
|
||||||
func (d *Database) RegisterAgent(agent *models.Agent) error {
|
func (d *Database) RegisterAgent(agent *models.Agent) error {
|
||||||
_, err := d.db.Exec(`
|
_, err := d.db.Exec(`
|
||||||
INSERT INTO agents (id, name, hostname, ip, opnsense_version, agent_version, registered_at)
|
INSERT INTO agents (id, name, hostname, ip, opnsense_version, agent_version, platform, registered_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name=EXCLUDED.name,
|
name=EXCLUDED.name,
|
||||||
hostname=EXCLUDED.hostname,
|
hostname=EXCLUDED.hostname,
|
||||||
ip=EXCLUDED.ip,
|
ip=EXCLUDED.ip,
|
||||||
opnsense_version=EXCLUDED.opnsense_version,
|
opnsense_version=EXCLUDED.opnsense_version,
|
||||||
agent_version=EXCLUDED.agent_version
|
agent_version=EXCLUDED.agent_version,
|
||||||
`, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.AgentVersion, agent.RegisteredAt.UTC())
|
platform=EXCLUDED.platform
|
||||||
|
`, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.AgentVersion, agent.Platform, agent.RegisteredAt.UTC())
|
||||||
return err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -377,7 +379,7 @@ func (d *Database) GetAgents() ([]models.Agent, error) {
|
|||||||
var lastHB sql.NullTime
|
var lastHB sql.NullTime
|
||||||
var custID sql.NullInt64
|
var custID sql.NullInt64
|
||||||
|
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.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
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastHB.Valid {
|
if lastHB.Valid {
|
||||||
@ -443,9 +445,9 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error
|
|||||||
var custID sql.NullInt64
|
var custID sql.NullInt64
|
||||||
|
|
||||||
err := d.db.QueryRow(
|
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,
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
|
|||||||
@ -10,6 +10,7 @@ type Agent struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
OPNsenseVersion string `json:"opnsense_version"`
|
OPNsenseVersion string `json:"opnsense_version"`
|
||||||
AgentVersion string `json:"agent_version,omitempty"`
|
AgentVersion string `json:"agent_version,omitempty"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
RegisteredAt time.Time `json:"registered_at"`
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
|
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
|
||||||
CustomerID *int `json:"customer_id,omitempty"`
|
CustomerID *int `json:"customer_id,omitempty"`
|
||||||
@ -55,6 +56,7 @@ type RegisterRequest struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
OPNsenseVersion string `json:"opnsense_version"`
|
OPNsenseVersion string `json:"opnsense_version"`
|
||||||
AgentVersion string `json:"agent_version,omitempty"`
|
AgentVersion string `json:"agent_version,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterResponse
|
// RegisterResponse
|
||||||
|
|||||||
@ -22,6 +22,8 @@ type SystemData struct {
|
|||||||
Updates *UpdateInfo `json:"updates,omitempty"`
|
Updates *UpdateInfo `json:"updates,omitempty"`
|
||||||
CronJobs []CronJob `json:"cron_jobs,omitempty"`
|
CronJobs []CronJob `json:"cron_jobs,omitempty"`
|
||||||
License *LicenseInfo `json:"license,omitempty"`
|
License *LicenseInfo `json:"license,omitempty"`
|
||||||
|
Proxmox map[string]interface{} `json:"proxmox,omitempty"`
|
||||||
|
Backups map[string]interface{} `json:"backups,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LicenseInfo struct {
|
type LicenseInfo struct {
|
||||||
|
|||||||
387
docs/BACKEND.md
387
docs/BACKEND.md
@ -169,6 +169,393 @@ Beim ersten Start:
|
|||||||
|
|
||||||
- `GetAgentByName`: Sucht Agent nach Name — verwendet fuer Pre-Registration Matching bei Agent-Registrierung
|
- `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 <token>`): 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 <TOKEN>" -X POST \
|
||||||
|
https://192.168.85.13:8443/api/v1/users \
|
||||||
|
-d '{"username":"operator","password":"Sicher!123"}'
|
||||||
|
|
||||||
|
# Passwort aendern
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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
|
## Hinweise
|
||||||
|
|
||||||
- Backend braucht **kein root** — laeuft als normaler User auf Port 8443
|
- Backend braucht **kein root** — laeuft als normaler User auf Port 8443
|
||||||
|
|||||||
@ -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 <TOKEN>" \
|
||||||
|
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 <TOKEN>" \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents
|
||||||
|
|
||||||
|
# Agent-Details mit Systemdaten
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee
|
||||||
|
|
||||||
|
# Firewall pre-registrieren
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" -X DELETE \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887
|
||||||
|
|
||||||
|
# Events (letzte 50)
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/events?limit=50"
|
||||||
|
|
||||||
|
# Kunden zuordnen
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" -X POST \
|
||||||
|
https://192.168.85.13:8443/api/v1/customers \
|
||||||
|
-d '{"number":"K05001","name":"Beispiel GmbH"}'
|
||||||
|
|
||||||
|
# Kunden aktualisieren
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" \
|
||||||
|
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 <TOKEN>" -X POST \
|
||||||
|
https://192.168.85.13:8443/api/v1/users \
|
||||||
|
-d '{"username":"operator","password":"Sicher!123"}'
|
||||||
|
|
||||||
|
# Passwort aendern
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" \
|
||||||
|
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics?metric=cpu_usage"
|
||||||
|
|
||||||
|
# RAM mit Zeitraum
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"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 <TOKEN>" \
|
||||||
|
"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 <TOKEN>" -X POST \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backup
|
||||||
|
|
||||||
|
# Backups auflisten
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups
|
||||||
|
|
||||||
|
# Als XML herunterladen
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"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 <TOKEN>" \
|
||||||
|
"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 <TOKEN>" -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 <TOKEN>" -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 <TOKEN>" -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 <TOKEN>" \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnels
|
||||||
|
|
||||||
|
# Tunnel schliessen
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" -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 <TOKEN>" \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers
|
||||||
|
|
||||||
|
# Peer loeschen
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" -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 <TOKEN>" -X POST \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../update-check
|
||||||
|
|
||||||
|
# Update mit Reboot
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" -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 <TOKEN>" \
|
||||||
|
https://192.168.85.13:8443/api/v1/firmware
|
||||||
|
|
||||||
|
# Firmware hochladen (FreeBSD)
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" -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 <TOKEN>" -X POST \
|
||||||
|
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../request-update
|
||||||
|
|
||||||
|
# Update fuer alle Agents
|
||||||
|
curl -sk -H "Authorization: Bearer <TOKEN>" -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 <TOKEN>" \
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Login from './pages/Login'
|
|||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Agents from './pages/Agents'
|
import Agents from './pages/Agents'
|
||||||
import AgentDetail from './pages/AgentDetail'
|
import AgentDetail from './pages/AgentDetail'
|
||||||
|
import ProxmoxServers from './pages/ProxmoxServers'
|
||||||
import Customers from './pages/Customers'
|
import Customers from './pages/Customers'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import Tunnels from './pages/Tunnels'
|
import Tunnels from './pages/Tunnels'
|
||||||
@ -51,6 +52,7 @@ export default function App() {
|
|||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="agents" element={<Agents />} />
|
<Route path="agents" element={<Agents />} />
|
||||||
<Route path="agents/:id" element={<AgentDetail />} />
|
<Route path="agents/:id" element={<AgentDetail />} />
|
||||||
|
<Route path="proxmox" element={<ProxmoxServers />} />
|
||||||
<Route path="tunnels" element={<Tunnels />} />
|
<Route path="tunnels" element={<Tunnels />} />
|
||||||
<Route path="customers" element={<Customers />} />
|
<Route path="customers" element={<Customers />} />
|
||||||
<Route path="firmware" element={<Firmware />} />
|
<Route path="firmware" element={<Firmware />} />
|
||||||
|
|||||||
@ -6,16 +6,17 @@ import {
|
|||||||
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone,
|
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const tabs = [
|
const allTabs = [
|
||||||
{ id: 'overview', label: 'Uebersicht' },
|
{ id: 'overview', label: 'Uebersicht' },
|
||||||
{ id: 'interfaces', label: 'Interfaces' },
|
{ id: 'interfaces', label: 'Interfaces' },
|
||||||
{ id: 'services', label: 'Dienste' },
|
{ id: 'services', label: 'Dienste' },
|
||||||
{ id: 'tunnels', label: 'Tunnel' },
|
{ id: 'tunnels', label: 'Tunnel' },
|
||||||
{ id: 'vpn', label: 'VPN' },
|
{ id: 'vpn', label: 'VPN', platforms: ['freebsd'] },
|
||||||
{ id: 'wireguard', label: 'WireGuard' },
|
{ id: 'wireguard', label: 'WireGuard', platforms: ['freebsd'] },
|
||||||
{ id: 'routes', label: 'Routen' },
|
{ id: 'routes', label: 'Routen' },
|
||||||
{ id: 'dhcp', label: 'DHCP' },
|
{ id: 'dhcp', label: 'DHCP', platforms: ['freebsd'] },
|
||||||
{ id: 'certs', label: 'Zertifikate' },
|
{ id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] },
|
||||||
|
{ id: 'updates', label: 'Updates', platforms: ['freebsd'] },
|
||||||
{ id: 'backups', label: 'Backups' },
|
{ id: 'backups', label: 'Backups' },
|
||||||
{ id: 'agent', label: 'Agent' },
|
{ id: 'agent', label: 'Agent' },
|
||||||
]
|
]
|
||||||
@ -44,6 +45,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
|||||||
|
|
||||||
const { agent, system_data: sys, status } = data
|
const { agent, system_data: sys, status } = data
|
||||||
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
||||||
|
const platform = agent.platform || 'freebsd'
|
||||||
|
const tabs = allTabs.filter(t => !t.platforms || t.platforms.includes(platform))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel onClose={onClose}>
|
<Panel onClose={onClose}>
|
||||||
@ -57,19 +60,19 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
|||||||
</div>
|
</div>
|
||||||
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{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 || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`Firewall "${agent.name}" wirklich loeschen?`)) return
|
if (!confirm(`Geraet "${agent.name}" wirklich loeschen?`)) return
|
||||||
await api.deleteAgent(agent.id)
|
await api.deleteAgent(agent.id)
|
||||||
if (onReload) onReload()
|
if (onReload) onReload()
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
title="Firewall loeschen"
|
title="Geraet loeschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -119,10 +122,12 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
|||||||
<DHCPTab sys={sys} />
|
<DHCPTab sys={sys} />
|
||||||
) : tab === 'certs' ? (
|
) : tab === 'certs' ? (
|
||||||
<CertsTab sys={sys} />
|
<CertsTab sys={sys} />
|
||||||
|
) : tab === 'updates' ? (
|
||||||
|
<UpdatesTab agentId={agentId} />
|
||||||
) : tab === 'backups' ? (
|
) : tab === 'backups' ? (
|
||||||
<BackupsTab agentId={agentId} />
|
platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
|
||||||
) : tab === 'agent' ? (
|
) : tab === 'agent' ? (
|
||||||
<AgentTab agent={data?.agent} status={data?.status} />
|
<AgentTab agent={data?.agent} status={data?.status} platform={platform} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
@ -253,7 +258,7 @@ function OverviewTab({ sys }) {
|
|||||||
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Firewall Status</h3>
|
<h3 className="text-sm font-medium text-gray-300 mt-4">System Status</h3>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<StatusCard icon={Shield} value={sys.opnsense_version || '—'} label="OPNsense" />
|
<StatusCard icon={Shield} value={sys.opnsense_version || '—'} label="OPNsense" />
|
||||||
<StatusCard icon={Network} value={ifaceCount} label="Interfaces" />
|
<StatusCard icon={Network} value={ifaceCount} label="Interfaces" />
|
||||||
@ -833,7 +838,7 @@ function WireGuardTab({ agentId, sys }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (noInstance) {
|
if (noInstance) {
|
||||||
return <div className="text-gray-500">Kein ALWAYSON_VPN Tunnel auf dieser Firewall gefunden.</div>
|
return <div className="text-gray-500">Kein ALWAYSON_VPN Tunnel auf diesem Geraet gefunden.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Jobs</div>
|
||||||
|
<div className="text-xl font-bold text-white">{totalJobs}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">30 Tage</div>
|
||||||
|
<div className="text-xl font-bold text-white">{tasks.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Erfolgsrate</div>
|
||||||
|
<div className={`text-xl font-bold ${successRate === 100 ? 'text-emerald-400' : successRate === null ? 'text-gray-500' : successRate >= 90 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||||
|
{successRate !== null ? `${successRate}%` : '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Naechstes</div>
|
||||||
|
<div className="text-sm font-bold text-white">
|
||||||
|
{nextRun ? nextRun.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ', ' + nextRun.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kalender */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button onClick={() => setMonthOffset(monthOffset - 1)} className="text-gray-400 hover:text-white px-2 py-1"><</button>
|
||||||
|
<h3 className="text-white font-semibold">{monthNames[month]} {year}</h3>
|
||||||
|
<button onClick={() => setMonthOffset(monthOffset + 1)} className="text-gray-400 hover:text-white px-2 py-1">></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center">
|
||||||
|
{dayNames.map(d => (
|
||||||
|
<div key={d} className="text-gray-500 text-xs py-1">{d}</div>
|
||||||
|
))}
|
||||||
|
{/* Leere Zellen vor dem 1. */}
|
||||||
|
{Array.from({ length: firstDayOfWeek }).map((_, i) => (
|
||||||
|
<div key={`empty-${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 (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={`relative py-2 rounded text-sm ${
|
||||||
|
isToday ? 'border border-emerald-500/50' :
|
||||||
|
'border border-transparent'
|
||||||
|
} ${hasOk ? 'bg-emerald-900/20' : hasFailed ? 'bg-red-900/20' : ''}`}
|
||||||
|
>
|
||||||
|
<span className={`${isToday ? 'text-emerald-400 font-bold' : 'text-gray-300'}`}>{day}</span>
|
||||||
|
{/* Dot indicators */}
|
||||||
|
<div className="flex justify-center gap-0.5 mt-0.5">
|
||||||
|
{hasOk && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />}
|
||||||
|
{hasFailed && <span className="w-1.5 h-1.5 rounded-full bg-red-400" />}
|
||||||
|
{isPlanned && !hasOk && !hasFailed && <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legende */}
|
||||||
|
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-400" /> OK</span>
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-400" /> Fehler</span>
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-400" /> Geplant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Konfigurierte Jobs */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-3">Konfigurierte Jobs</h3>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">Keine Backup-Jobs konfiguriert</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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 (
|
||||||
|
<div key={i} className="bg-gray-900 rounded p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
||||||
|
<span className="text-white text-sm font-medium">{vmids}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-xs mt-1">
|
||||||
|
{job.schedule} · {job.storage} · {job.mode}
|
||||||
|
{job.node && <span> · Node: {job.node}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nr && (
|
||||||
|
<div className="text-right text-xs text-gray-400">
|
||||||
|
Naechstes: {nr.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}, {nr.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Letzte Backups */}
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-3">Letzte Backups ({tasks.length})</h3>
|
||||||
|
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||||
|
{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 (
|
||||||
|
<div key={i} className="flex items-center justify-between text-xs py-1 border-b border-gray-800/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${t.status === 'OK' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{duration !== null && <span className="text-gray-500">{duration} min</span>}
|
||||||
|
<span className={`${t.status === 'OK' ? 'text-emerald-400' : 'text-red-400'}`}>{t.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BackupsTab({ agentId }) {
|
function BackupsTab({ agentId }) {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Update Check */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-white font-semibold">Update-Check</h3>
|
||||||
|
<button
|
||||||
|
onClick={checkUpdates}
|
||||||
|
disabled={checking}
|
||||||
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-3 py-1.5 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
{checking ? 'Pruefe...' : 'Updates pruefen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result?.error && (
|
||||||
|
<div className="text-red-400 text-sm bg-red-900/20 rounded p-2">{result.error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !result.error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Core Update */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${result.core_update_available ? 'bg-orange-400' : 'bg-emerald-400'}`} />
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{result.core_update_available ? 'Core-Update verfuegbar' : 'Core ist aktuell'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.core_update_info && result.core_update_available && (
|
||||||
|
<pre className="bg-gray-900 text-gray-400 text-xs p-2 rounded overflow-x-auto max-h-32">{result.core_update_info}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reboot Required */}
|
||||||
|
{result.reboot_required && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||||
|
<span className="text-sm text-red-400 font-medium">Reboot erforderlich</span>
|
||||||
|
<button onClick={runReboot} className="ml-auto text-xs bg-red-600 hover:bg-red-500 text-white px-2 py-1 rounded">
|
||||||
|
Jetzt neustarten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Package Updates */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${pendingPkgs.length > 0 ? 'bg-orange-400' : 'bg-emerald-400'}`} />
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{pendingPkgs.length > 0 ? `${pendingPkgs.length} Paket-Updates verfuegbar` : 'Alle Pakete aktuell'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Package List */}
|
||||||
|
{pendingPkgs.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-500 border-b border-gray-800">
|
||||||
|
<th className="text-left px-3 py-1.5">Paket</th>
|
||||||
|
<th className="text-left px-3 py-1.5">Aktuell</th>
|
||||||
|
<th className="text-left px-3 py-1.5">Neu</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pendingPkgs.map((pkg, i) => (
|
||||||
|
<tr key={i} className="border-b border-gray-800/50">
|
||||||
|
<td className="px-3 py-1 text-gray-300 font-mono">{pkg.package}</td>
|
||||||
|
<td className="px-3 py-1 text-gray-500">{pkg.current_version}</td>
|
||||||
|
<td className="px-3 py-1 text-emerald-400">{pkg.new_version}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Normales Update */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-1">Normales Update</h3>
|
||||||
|
<p className="text-gray-500 text-xs mb-3">OPNsense Core + Paket-Updates installieren.</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={runUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{updating ? 'Update laeuft...' : 'Update starten'}
|
||||||
|
</button>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={reboot}
|
||||||
|
onChange={(e) => setReboot(e.target.checked)}
|
||||||
|
className="rounded bg-gray-700 border-gray-600 text-orange-500 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
Nach Update neustarten
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Major Update */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-1">Major-Update</h3>
|
||||||
|
<p className="text-gray-500 text-xs mb-3">
|
||||||
|
Upgrade auf neue OPNsense-Version (2 Phasen: Phase 1 = Base+Kernel + Reboot, Phase 2 = Packages).
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={majorVer}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={majorPhase}
|
||||||
|
onChange={(e) => setMajorPhase(e.target.value)}
|
||||||
|
className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:border-orange-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="1">Phase 1 (Base+Kernel)</option>
|
||||||
|
<option value="2">Phase 2 (Packages)</option>
|
||||||
|
</select>
|
||||||
|
{majorPhase === '1' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={majorReboot}
|
||||||
|
onChange={(e) => setMajorReboot(e.target.checked)}
|
||||||
|
className="rounded bg-gray-700 border-gray-600 text-orange-500 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
Reboot
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={runMajorUpdate}
|
||||||
|
disabled={updating || !majorVer}
|
||||||
|
className="flex items-center gap-2 bg-red-600 hover:bg-red-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
{updating ? 'Update laeuft...' : `Major-Update Phase ${majorPhase} starten`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reboot */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-1">Reboot</h3>
|
||||||
|
<p className="text-gray-500 text-xs mb-3">Geraet manuell neu starten.</p>
|
||||||
|
<button
|
||||||
|
onClick={runReboot}
|
||||||
|
className="flex items-center gap-2 bg-red-600/80 hover:bg-red-600 text-white px-4 py-2 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Jetzt neustarten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update-Ergebnis */}
|
||||||
|
{updateResult && (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-2">Ergebnis</h3>
|
||||||
|
{updateResult.error ? (
|
||||||
|
<div className="text-red-400 text-sm">{updateResult.error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{updateResult.message && (
|
||||||
|
<div className="text-emerald-400 text-sm">{updateResult.message}</div>
|
||||||
|
)}
|
||||||
|
{updateResult.steps?.map((step, i) => (
|
||||||
|
<div key={i} className="bg-gray-900 rounded p-2">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${step.ok ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||||
|
<span className="text-gray-300 text-xs font-mono">{step.step}</span>
|
||||||
|
</div>
|
||||||
|
{step.output && (
|
||||||
|
<pre className="text-gray-500 text-xs overflow-x-auto max-h-40 whitespace-pre-wrap">{step.output}</pre>
|
||||||
|
)}
|
||||||
|
{step.error && <div className="text-red-400 text-xs mt-1">{step.error}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{updateResult.reboot_required && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||||
|
Reboot erforderlich
|
||||||
|
{!updateResult.reboot_requested && (
|
||||||
|
<button onClick={runReboot} className="ml-2 text-xs bg-red-600 hover:bg-red-500 text-white px-2 py-1 rounded">
|
||||||
|
Jetzt neustarten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentTab({ agent, status, platform }) {
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
@ -1275,13 +1749,17 @@ function AgentTab({ agent, status }) {
|
|||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
<h3 className="text-white font-semibold mb-3">Nuetzliche Befehle</h3>
|
<h3 className="text-white font-semibold mb-3">Nuetzliche Befehle</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{(platform === 'linux' ? [
|
||||||
{ label: 'Service Status pruefen:', cmd: 'service rmm_agent status' },
|
{ 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: 'Logs anzeigen:', cmd: 'tail -f /var/log/rmm-agent.log' },
|
||||||
{ label: 'Agent neustarten:', cmd: 'service rmm_agent restart' },
|
{ label: 'Agent neustarten:', cmd: 'service rmm_agent restart' },
|
||||||
{ label: 'Updater Status:', cmd: 'service rmm_updater status' },
|
{ label: 'Updater Status:', cmd: 'service rmm_updater status' },
|
||||||
{ label: 'Updater Logs:', cmd: 'tail -f /var/log/rmm-updater.log' },
|
{ label: 'Updater Logs:', cmd: 'tail -f /var/log/rmm-updater.log' },
|
||||||
].map((item, i) => (
|
]).map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-3">
|
<div key={i} className="flex items-center gap-3">
|
||||||
<span className="text-gray-500 text-xs w-40 shrink-0">{item.label}</span>
|
<span className="text-gray-500 text-xs w-40 shrink-0">{item.label}</span>
|
||||||
<code className="bg-gray-900 text-gray-300 text-xs px-2 py-1 rounded font-mono">{item.cmd}</code>
|
<code className="bg-gray-900 text-gray-300 text-xs px-2 py-1 rounded font-mono">{item.cmd}</code>
|
||||||
@ -1294,6 +1772,10 @@ function AgentTab({ agent, status }) {
|
|||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
<h3 className="text-white font-semibold mb-3">Agent Info</h3>
|
<h3 className="text-white font-semibold mb-3">Agent Info</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500 text-sm">Plattform:</span>
|
||||||
|
<span className="text-white text-sm font-medium">{platform === 'linux' ? 'Linux' : platform === 'freebsd' ? 'FreeBSD / OPNsense' : platform}</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500 text-sm">Agent Version:</span>
|
<span className="text-gray-500 text-sm">Agent Version:</span>
|
||||||
<span className="text-white text-sm font-medium">{agent?.agent_version || '--'}</span>
|
<span className="text-white text-sm font-medium">{agent?.agent_version || '--'}</span>
|
||||||
|
|||||||
948
frontend/src/components/ProxmoxPanel.jsx
Normal file
948
frontend/src/components/ProxmoxPanel.jsx
Normal file
@ -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 <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
|
||||||
|
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
|
||||||
|
|
||||||
|
const { agent, system_data: sys, status } = data
|
||||||
|
const proxmox = sys?.proxmox
|
||||||
|
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
||||||
|
|
||||||
|
if (!proxmox?.available) {
|
||||||
|
return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel onClose={onClose}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 bg-gradient-to-r from-gray-800 to-gray-900 border-b border-gray-700">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-bold text-white">{agent.name}</h2>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</div>
|
||||||
|
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{agent.hostname} · IP: {agent.ip} · PVE {proxmox.version || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Server "${agent.name}" wirklich loeschen?`)) return
|
||||||
|
await api.deleteAgent(agent.id)
|
||||||
|
if (onReload) onReload()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
title="Server loeschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-t whitespace-nowrap transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? 'bg-gray-900 text-orange-400 border-t border-x border-gray-700'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{!sys ? (
|
||||||
|
<div className="text-gray-500">Keine Systemdaten</div>
|
||||||
|
) : tab === 'overview' ? (
|
||||||
|
<OverviewTab sys={sys} proxmox={proxmox} />
|
||||||
|
) : tab === 'vms' ? (
|
||||||
|
<VmsTab proxmox={proxmox} />
|
||||||
|
) : tab === 'containers' ? (
|
||||||
|
<ContainersTab proxmox={proxmox} />
|
||||||
|
) : tab === 'storage' ? (
|
||||||
|
<StorageTab proxmox={proxmox} />
|
||||||
|
) : tab === 'zfs' ? (
|
||||||
|
<ZfsTab proxmox={proxmox} />
|
||||||
|
) : tab === 'tunnel' ? (
|
||||||
|
<TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
|
||||||
|
) : tab === 'services' ? (
|
||||||
|
<ServicesTab sys={sys} />
|
||||||
|
) : tab === 'updates' ? (
|
||||||
|
<UpdatesTab sys={sys} />
|
||||||
|
) : tab === 'backups' ? (
|
||||||
|
<BackupsTab sys={sys} />
|
||||||
|
) : tab === 'agent' ? (
|
||||||
|
<AgentTab agent={data?.agent} status={data?.status} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
{!editing ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="hover:text-orange-400 transition-colors"
|
||||||
|
>
|
||||||
|
Kunde: {current ? `${current.number} — ${current.name}` : 'Kein Kunde'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
onChange={handleChange}
|
||||||
|
defaultValue={current?.id || ''}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-gray-800 border border-gray-600 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
<option value="">Kein Kunde</option>
|
||||||
|
{customers.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ onClose, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-5xl bg-gray-900 border-l border-gray-800 flex flex-col shadow-2xl animate-slide-in">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||||
|
.animate-slide-in { animation: slideIn 0.2s ease-out; }
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 mb-2">
|
||||||
|
{Icon && <Icon className="w-4 h-4" />}
|
||||||
|
<span className="text-xs uppercase">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">{value}</div>
|
||||||
|
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
||||||
|
{bar !== undefined && bar !== null && (
|
||||||
|
<div className="mt-2 h-1.5 bg-gray-700 rounded overflow-hidden">
|
||||||
|
<div className={`h-full rounded ${barColor || 'bg-blue-500'}`} style={{ width: `${Math.min(bar, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ icon: Icon, value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
{Icon && <Icon className="w-5 h-5 text-gray-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">{value}</div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">System-Metriken</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<MetricCard icon={Cpu} label="CPU Auslastung" value={cpuPct != null ? `${cpuPct.toFixed(1)}%` : '—'}
|
||||||
|
sub={sys.cpu?.model} bar={cpuPct} barColor={barColor(cpuPct || 0)} />
|
||||||
|
<MetricCard icon={MemoryStick} label="RAM Auslastung" value={ramPct != null ? `${ramPct.toFixed(1)}%` : '—'}
|
||||||
|
sub={sys.memory ? `${formatBytes(sys.memory.used_bytes)} / ${formatBytes(sys.memory.total_bytes)}` : ''}
|
||||||
|
bar={ramPct} barColor={barColor(ramPct || 0)} />
|
||||||
|
<MetricCard icon={HardDrive} label="Festplatte" value={diskPct != null ? `${diskPct.toFixed(1)}%` : '—'}
|
||||||
|
sub={rootDisk ? `${formatBytes(rootDisk.used_bytes)} / ${formatBytes(rootDisk.total_bytes)}` : ''}
|
||||||
|
bar={diskPct} barColor={barColor(diskPct || 0)} />
|
||||||
|
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Proxmox-Info</h3>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">PVE Version</div>
|
||||||
|
<div className="text-white font-medium">{proxmox.version || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Node Name</div>
|
||||||
|
<div className="text-white font-medium">{proxmox.node || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Cluster Name</div>
|
||||||
|
<div className="text-white font-medium">{proxmox.cluster?.name || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscription */}
|
||||||
|
{proxmox.subscription && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Subscription</h3>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{proxmox.subscription.productname || 'Proxmox VE'}</div>
|
||||||
|
{proxmox.subscription.key && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1 font-mono">{proxmox.subscription.key}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`text-sm font-bold ${
|
||||||
|
proxmox.subscription.status === 'Active' ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{proxmox.subscription.status || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
{proxmox.subscription.next_due_date && (
|
||||||
|
<div className="text-xs text-gray-500">bis {proxmox.subscription.next_due_date}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mt-4">Kurzuebersicht</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<StatusCard icon={Monitor} value={`${vmsRunning}/${vmsTotal}`} label="VMs" />
|
||||||
|
<StatusCard icon={Container} value={`${ctRunning}/${ctTotal}`} label="Container" />
|
||||||
|
<StatusCard icon={Database} value={storageCount} label="Storage" />
|
||||||
|
<StatusCard icon={FolderTree} value={zfsCount} label="ZFS Pools" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
|
||||||
|
{sortedVms.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine VMs gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">VMID</th>
|
||||||
|
<th className="px-3 py-2">Name</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
<th className="px-3 py-2">CPU</th>
|
||||||
|
<th className="px-3 py-2">RAM</th>
|
||||||
|
<th className="px-3 py-2">Disk</th>
|
||||||
|
<th className="px-3 py-2">Uptime</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{sortedVms.map((vm) => (
|
||||||
|
<tr key={vm.vmid}>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{vm.vmid}</td>
|
||||||
|
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<StatusBadge status={vm.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{vm.memory_used && vm.memory_max ?
|
||||||
|
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{vm.disk_used != null && vm.disk_max ?
|
||||||
|
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
|
||||||
|
{sortedCt.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine Container gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">VMID</th>
|
||||||
|
<th className="px-3 py-2">Name</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
<th className="px-3 py-2">CPU</th>
|
||||||
|
<th className="px-3 py-2">RAM</th>
|
||||||
|
<th className="px-3 py-2">Uptime</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{sortedCt.map((ct) => (
|
||||||
|
<tr key={ct.vmid}>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{ct.vmid}</td>
|
||||||
|
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<StatusBadge status={ct.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{ct.memory_used && ct.memory_max ?
|
||||||
|
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StorageTab({ proxmox }) {
|
||||||
|
const storage = proxmox.storage || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Storage ({storage.length})</h3>
|
||||||
|
{storage.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine Storage-Pools gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<div key={s.storage} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{s.storage}</div>
|
||||||
|
<div className="text-xs text-gray-500">{s.type} • {s.content}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<StatusBadge status={s.active ? 'online' : 'offline'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{s.total && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-400">{formatBytes(s.used)} / {formatBytes(s.total)}</span>
|
||||||
|
<span className="text-gray-400">{usedPct.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden">
|
||||||
|
<div className={`h-full rounded ${barColor}`} style={{ width: `${usedPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZfsTab({ proxmox }) {
|
||||||
|
const zfsPools = proxmox.zfs_pools || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">ZFS Pools ({zfsPools.length})</h3>
|
||||||
|
{zfsPools.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine ZFS-Pools gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{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 (
|
||||||
|
<div key={pool.name} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="text-white font-medium">{pool.name}</div>
|
||||||
|
<div className={`text-xs font-bold ${healthColor}`}>{pool.health}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Groesse:</span>
|
||||||
|
<span className="text-gray-300">{formatBytes(pool.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Belegt:</span>
|
||||||
|
<span className="text-gray-300">{formatBytes(pool.allocated)} ({usedPct.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Frei:</span>
|
||||||
|
<span className="text-gray-300">{formatBytes(pool.free)}</span>
|
||||||
|
</div>
|
||||||
|
{pool.fragmentation && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Fragmentierung:</span>
|
||||||
|
<span className="text-gray-300">{pool.fragmentation}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 w-full h-2 bg-gray-700 rounded overflow-hidden">
|
||||||
|
<div className={`h-full rounded ${pctColor}`} style={{ width: `${Math.min(usedPct, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pool.scrub_status && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||||
|
<div className="text-xs text-gray-500">{pool.scrub_status}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Systemd Dienste ({services.length})</h3>
|
||||||
|
{sortedServices.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine Dienste gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">Name</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{sortedServices.map((svc, i) => {
|
||||||
|
const running = svc.running || svc.status === 'running'
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-3 py-2 text-white">{svc.name}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<StatusBadge status={running ? 'running' : 'stopped'} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdatesTab({ sys }) {
|
||||||
|
const pendingUpdates = sys.pending_updates || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Updates</h3>
|
||||||
|
<div className="text-sm text-orange-400">
|
||||||
|
{pendingUpdates.length} Updates verfuegbar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingUpdates.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine Updates verfuegbar</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">Package</th>
|
||||||
|
<th className="px-3 py-2">Current Version</th>
|
||||||
|
<th className="px-3 py-2">New Version</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{pendingUpdates.map((update, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-3 py-2 text-white font-medium">{update.package}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{update.current_version}</td>
|
||||||
|
<td className="px-3 py-2 text-orange-400">{update.new_version}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentTab({ agent, status }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Agent Information</h3>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Agent Version</div>
|
||||||
|
<div className="text-white">{agent?.agent_version || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Status</div>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Last Heartbeat</div>
|
||||||
|
<div className="text-white text-xs">
|
||||||
|
{agent?.last_heartbeat ? new Date(agent.last_heartbeat).toLocaleString('de-DE') : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase">Agent ID</div>
|
||||||
|
<div className="text-white text-xs font-mono">{agent?.id?.substring(0, 12)}...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-400">Schnellzugriff:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => openTunnel('127.0.0.1', webguiPort, 'webgui')}
|
||||||
|
disabled={creating === 'webgui'}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
||||||
|
>
|
||||||
|
<MonitorSmartphone className="w-4 h-4" />
|
||||||
|
{creating === 'webgui' ? 'Verbinde...' : `WebGUI (${webguiPort})`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openTunnel('127.0.0.1', 22, 'ssh')}
|
||||||
|
disabled={creating === 'ssh'}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
{creating === 'ssh' ? 'Verbinde...' : 'SSH (22)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCustomOpen(!customOpen)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Cable className="w-4 h-4" /> Benutzerdefiniert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customOpen && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="text" value={customHost} onChange={(e) => 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" />
|
||||||
|
<span className="text-gray-500">:</span>
|
||||||
|
<input type="number" value={customPort} onChange={(e) => 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()} />
|
||||||
|
<button onClick={handleCustom} className="px-3 py-1 bg-orange-600 hover:bg-orange-500 rounded text-white transition-colors">Verbinden</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="text-red-400 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
{tunnels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Aktive Tunnel ({tunnels.length})</h3>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">Ziel</th>
|
||||||
|
<th className="px-3 py-2">Proxy Port</th>
|
||||||
|
<th className="px-3 py-2">Zugriff</th>
|
||||||
|
<th className="px-3 py-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{tunnels.map((t) => (
|
||||||
|
<tr key={t.tunnel_id || t.id}>
|
||||||
|
<td className="px-3 py-2 text-white">{t.target_host}:{t.target_port}</td>
|
||||||
|
<td className="px-3 py-2 text-orange-400 font-mono">{t.proxy_port}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<a href={`https://${backendHost}:${t.proxy_port}`} target="_blank" rel="noreferrer"
|
||||||
|
className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
||||||
|
<ExternalLink className="w-3 h-3" /> Oeffnen
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<button onClick={() => closeTunnel(t.tunnel_id || t.id)} className="text-gray-500 hover:text-red-400">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Jobs (aktiv)</div>
|
||||||
|
<div className="text-xl font-bold text-white">{totalJobs}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Aufgaben (30d)</div>
|
||||||
|
<div className="text-xl font-bold text-white">{tasks.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Erfolgreich</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-400">{okTasks}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||||
|
<div className="text-gray-500 text-xs mb-1">Erfolgsrate</div>
|
||||||
|
<div className={`text-xl font-bold ${successRate === 100 ? 'text-emerald-400' : successRate === null ? 'text-gray-500' : successRate >= 90 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||||
|
{successRate !== null ? `${successRate}%` : '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backup Jobs */}
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Backup Jobs</h3>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine Backup-Jobs konfiguriert</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">ID</th>
|
||||||
|
<th className="px-3 py-2">Storage</th>
|
||||||
|
<th className="px-3 py-2">Schedule</th>
|
||||||
|
<th className="px-3 py-2">Modus</th>
|
||||||
|
<th className="px-3 py-2">VMs/CTs</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{jobs.map((j, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-3 py-2 text-white font-mono text-xs">{j.id || i}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{j.storage || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400 text-xs">{j.schedule || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{j.mode || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400 text-xs">{j.vmid || j.all ? 'Alle' : '—'}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`text-xs ${j.enabled ? 'text-green-400' : 'text-gray-500'}`}>
|
||||||
|
{j.enabled ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task History */}
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Letzte Backup-Aufgaben</h3>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="text-gray-500">Keine Backup-Aufgaben in den letzten 30 Tagen</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||||
|
<th className="px-3 py-2">Zeitpunkt</th>
|
||||||
|
<th className="px-3 py-2">Typ</th>
|
||||||
|
<th className="px-3 py-2">VMID</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{tasks.slice(0, 50).map((t, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-3 py-2 text-gray-400 text-xs">
|
||||||
|
{t.starttime ? new Date(t.starttime * 1000).toLocaleString('de-DE') : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400 text-xs">{t.type || 'vzdump'}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{t.id || '—'}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||||
|
t.status === 'OK'
|
||||||
|
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
||||||
|
: 'bg-red-900/40 text-red-400 border border-red-700/50'
|
||||||
|
}`}>
|
||||||
|
{t.status || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failedTasks > 0 && (
|
||||||
|
<div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">
|
||||||
|
{failedTasks} fehlgeschlagene Backup(s) in den letzten 30 Tagen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
@ -12,11 +12,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
Cable,
|
Cable,
|
||||||
Download,
|
Download,
|
||||||
|
HardDrive,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/agents', label: 'Firewalls', icon: Server },
|
{ path: '/agents', label: 'Firewalls', icon: Server },
|
||||||
|
{ path: '/proxmox', label: 'Proxmox', icon: HardDrive },
|
||||||
{ path: '/tunnels', label: 'Tunnel', icon: Cable },
|
{ path: '/tunnels', label: 'Tunnel', icon: Cable },
|
||||||
{ path: '/firmware', label: 'Firmware', icon: Download },
|
{ path: '/firmware', label: 'Firmware', icon: Download },
|
||||||
{ path: '/customers', label: 'Kunden', icon: Users },
|
{ path: '/customers', label: 'Kunden', icon: Users },
|
||||||
|
|||||||
@ -63,6 +63,10 @@ export default function Agents() {
|
|||||||
const detail = agentDetails[a.id]
|
const detail = agentDetails[a.id]
|
||||||
const sys = detail?.system_data
|
const sys = detail?.system_data
|
||||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||||
|
|
||||||
|
// Nur OPNsense/FreeBSD Agents anzeigen
|
||||||
|
if (a.platform === 'linux') return null
|
||||||
|
|
||||||
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
|
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
|
||||||
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
||||||
? (rootDisk.used_bytes / rootDisk.total_bytes * 100)
|
? (rootDisk.used_bytes / rootDisk.total_bytes * 100)
|
||||||
@ -85,7 +89,7 @@ export default function Agents() {
|
|||||||
gateways,
|
gateways,
|
||||||
sys,
|
sys,
|
||||||
}
|
}
|
||||||
})
|
}).filter(Boolean) // Entferne null Werte
|
||||||
}, [agents, agentDetails, customerMap])
|
}, [agents, agentDetails, customerMap])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@ -138,12 +142,12 @@ export default function Agents() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white">Firewalls ({agents.length})</h1>
|
<h1 className="text-xl font-bold text-white">Geraete ({agents.length})</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> Firewall hinzufuegen
|
<Plus className="w-4 h-4" /> Geraet hinzufuegen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -192,7 +196,14 @@ export default function Agents() {
|
|||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-white font-medium">{a.name}</td>
|
<td className="px-3 py-2 text-white font-medium">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-mono px-1 rounded ${a.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}>
|
||||||
|
{a.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||||
|
</span>
|
||||||
|
{a.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
||||||
<td className="px-3 py-2 text-gray-400">{a.version || '—'}</td>
|
<td className="px-3 py-2 text-gray-400">{a.version || '—'}</td>
|
||||||
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
||||||
@ -227,7 +238,7 @@ export default function Agents() {
|
|||||||
</table>
|
</table>
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
{search ? 'Keine Treffer' : 'Keine Firewalls registriert'}
|
{search ? 'Keine Treffer' : 'Keine Geraete registriert'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -243,9 +254,9 @@ export default function Agents() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Firewall hinzufuegen Dialog */}
|
{/* Geraet hinzufuegen Dialog */}
|
||||||
{showAddDialog && (
|
{showAddDialog && (
|
||||||
<AddFirewallDialog
|
<AddDeviceDialog
|
||||||
customers={customers}
|
customers={customers}
|
||||||
result={addResult}
|
result={addResult}
|
||||||
onAdd={async (name, custId) => {
|
onAdd={async (name, custId) => {
|
||||||
@ -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 [name, setName] = useState('')
|
||||||
const [custId, setCustId] = useState('')
|
const [custId, setCustId] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@ -299,7 +310,7 @@ function AddFirewallDialog({ customers, result, onAdd, onClose }) {
|
|||||||
# 1. Ins RMM-Verzeichnis wechseln:
|
# 1. Ins RMM-Verzeichnis wechseln:
|
||||||
cd /Users/cynfo/.openclaw/workspace/rmm
|
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@<FIREWALL-IP>:/tmp/
|
scp build/rmm-agent build/rmm-updater root@<FIREWALL-IP>:/tmp/
|
||||||
scp opnsense-plugin/install.sh root@<FIREWALL-IP>:/tmp/
|
scp opnsense-plugin/install.sh root@<FIREWALL-IP>:/tmp/
|
||||||
|
|
||||||
@ -316,7 +327,7 @@ ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||||
<h2 className="text-lg font-semibold text-white">Firewall hinzufuegen</h2>
|
<h2 className="text-lg font-semibold text-white">Geraet hinzufuegen</h2>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -354,13 +365,13 @@ ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
|||||||
disabled={submitting}
|
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"
|
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'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
||||||
Firewall "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
Geraet "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -2,23 +2,39 @@ import { useEffect, useState } from 'react'
|
|||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import AgentPanel from '../components/AgentPanel'
|
import AgentPanel from '../components/AgentPanel'
|
||||||
import { 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() {
|
export default function Dashboard() {
|
||||||
const [agents, setAgents] = useState([])
|
const [agents, setAgents] = useState([])
|
||||||
const [customers, setCustomers] = useState([])
|
const [customers, setCustomers] = useState([])
|
||||||
|
const [agentDetails, setAgentDetails] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedId, setSelectedId] = useState(null)
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox'
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
Promise.all([api.getAgents(), api.getCustomers()])
|
Promise.all([api.getAgents(), api.getCustomers()])
|
||||||
.then(([a, c]) => {
|
.then(([a, c]) => {
|
||||||
setAgents(a || [])
|
setAgents(a || [])
|
||||||
setCustomers(c || [])
|
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))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isProxmox = (agentId) => {
|
||||||
|
const d = agentDetails[agentId]
|
||||||
|
return d?.system_data?.proxmox?.available === true
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload()
|
reload()
|
||||||
}, [])
|
}, [])
|
||||||
@ -48,8 +64,9 @@ export default function Dashboard() {
|
|||||||
<h1 className="text-xl font-bold text-white">Dashboard</h1>
|
<h1 className="text-xl font-bold text-white">Dashboard</h1>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
<StatCard icon={Server} label="Firewalls" value={agents.length} color="text-blue-400" />
|
<StatCard icon={Server} label="Geraete" value={agents.filter(a => !isProxmox(a.id)).length} color="text-blue-400" />
|
||||||
|
<StatCard icon={HardDrive} label="Proxmox" value={agents.filter(a => isProxmox(a.id)).length} color="text-purple-400" />
|
||||||
<StatCard icon={Activity} label="Online" value={online} color="text-green-400" />
|
<StatCard icon={Activity} label="Online" value={online} color="text-green-400" />
|
||||||
<StatCard icon={AlertTriangle} label="Offline" value={offline} color="text-red-400" />
|
<StatCard icon={AlertTriangle} label="Offline" value={offline} color="text-red-400" />
|
||||||
<StatCard icon={Users} label="Kunden" value={customers.length} color="text-orange-400" />
|
<StatCard icon={Users} label="Kunden" value={customers.length} color="text-orange-400" />
|
||||||
@ -67,12 +84,12 @@ export default function Dashboard() {
|
|||||||
<span className="text-orange-400">{customer.number}</span> — {customer.name}
|
<span className="text-orange-400">{customer.number}</span> — {customer.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'}
|
{customer.agents.length} {customer.agents.length === 1 ? 'Geraet' : 'Geraete'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-800">
|
<div className="divide-y divide-gray-800">
|
||||||
{customer.agents.map((agent) => (
|
{customer.agents.map((agent) => (
|
||||||
<AgentRow key={agent.id} agent={agent} onClick={() => setSelectedId(agent.id)} selected={selectedId === agent.id} />
|
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -88,19 +105,27 @@ export default function Dashboard() {
|
|||||||
{agents
|
{agents
|
||||||
.filter((a) => !a.customer_id)
|
.filter((a) => !a.customer_id)
|
||||||
.map((agent) => (
|
.map((agent) => (
|
||||||
<AgentRow key={agent.id} agent={agent} onClick={() => setSelectedId(agent.id)} selected={selectedId === agent.id} />
|
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Panel */}
|
{/* Detail Panel — Geraet-Detail */}
|
||||||
{selectedId && (
|
{selectedId && selectedType === 'proxmox' && (
|
||||||
|
<ProxmoxPanel
|
||||||
|
agentId={selectedId}
|
||||||
|
customers={customers}
|
||||||
|
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||||
|
onReload={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedId && selectedType !== 'proxmox' && (
|
||||||
<AgentPanel
|
<AgentPanel
|
||||||
agentId={selectedId}
|
agentId={selectedId}
|
||||||
customers={customers}
|
customers={customers}
|
||||||
onClose={() => setSelectedId(null)}
|
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||||
onReload={reload}
|
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
|
const lastHB = agent.last_heartbeat
|
||||||
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
|
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
|
||||||
: '—'
|
: '—'
|
||||||
@ -135,7 +160,12 @@ function AgentRow({ agent, onClick, selected }) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<StatusBadge status={agent.status} size="dot" />
|
<StatusBadge status={agent.status} size="dot" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-white">{agent.name}</div>
|
<div className="text-sm text-white inline-flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-mono px-1 rounded ${agent.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}>
|
||||||
|
{agent.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||||
|
</span>
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-500">{agent.ip}</div>
|
<div className="text-xs text-gray-500">{agent.ip}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -102,14 +102,19 @@ export default function Firmware() {
|
|||||||
|
|
||||||
if (loading) return <div className="text-gray-500">Laden...</div>
|
if (loading) return <div className="text-gray-500">Laden...</div>
|
||||||
|
|
||||||
// Fuer jetzt: alle Agents sind freebsd
|
const getFwForAgent = (agent) => {
|
||||||
const freebsdFw = getFwForPlatform('freebsd')
|
const p = agent.platform || 'freebsd'
|
||||||
|
return getFwForPlatform(p)
|
||||||
|
}
|
||||||
|
|
||||||
const needsUpdate = (agent) => {
|
const needsUpdate = (agent) => {
|
||||||
if (!freebsdFw) return false
|
const fw = getFwForAgent(agent)
|
||||||
return agent.agent_version !== freebsdFw.version
|
if (!fw) return false
|
||||||
|
return agent.agent_version !== fw.version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAnyFirmware = PLATFORMS.some(p => getFwForPlatform(p.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-white">Agent-Firmware</h1>
|
<h1 className="text-2xl font-bold text-white">Agent-Firmware</h1>
|
||||||
@ -201,7 +206,7 @@ export default function Firmware() {
|
|||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Agents</h2>
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Agents</h2>
|
||||||
{freebsdFw && (
|
{hasAnyFirmware && (
|
||||||
<button
|
<button
|
||||||
onClick={requestAll}
|
onClick={requestAll}
|
||||||
className="flex items-center gap-2 bg-orange-600/20 hover:bg-orange-600/30 text-orange-400 px-3 py-1.5 rounded text-xs transition-colors border border-orange-600/30"
|
className="flex items-center gap-2 bg-orange-600/20 hover:bg-orange-600/30 text-orange-400 px-3 py-1.5 rounded text-xs transition-colors border border-orange-600/30"
|
||||||
@ -216,6 +221,10 @@ export default function Firmware() {
|
|||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const outdated = needsUpdate(agent)
|
const outdated = needsUpdate(agent)
|
||||||
const pending = agent.update_requested
|
const pending = agent.update_requested
|
||||||
|
const agentPlatform = agent.platform || 'freebsd'
|
||||||
|
const fw = getFwForAgent(agent)
|
||||||
|
const platformLabel = agentPlatform === 'linux' ? 'Linux' : 'FreeBSD'
|
||||||
|
const platformColor = agentPlatform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'
|
||||||
return (
|
return (
|
||||||
<div key={agent.id} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30">
|
<div key={agent.id} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -225,18 +234,20 @@ export default function Firmware() {
|
|||||||
agent.status === 'stale' ? 'bg-yellow-500' : 'bg-gray-600'
|
agent.status === 'stale' ? 'bg-yellow-500' : 'bg-gray-600'
|
||||||
}`} />
|
}`} />
|
||||||
<span className="text-white text-sm font-medium truncate">{agent.name}</span>
|
<span className="text-white text-sm font-medium truncate">{agent.name}</span>
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/50 text-gray-500">FreeBSD</span>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${platformColor}`}>{platformLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
Version: <span className={outdated ? 'text-yellow-400' : 'text-gray-400'}>{agent.agent_version || '--'}</span>
|
Version: <span className={outdated ? 'text-yellow-400' : 'text-gray-400'}>{agent.agent_version || '--'}</span>
|
||||||
|
{fw && <span className="text-gray-600 ml-1">(aktuell: v{fw.version})</span>}
|
||||||
|
{!fw && <span className="text-gray-600 ml-1">(keine Firmware fuer {platformLabel})</span>}
|
||||||
</span>
|
</span>
|
||||||
{pending && (
|
{pending && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||||
Update ausstehend
|
Update ausstehend
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!outdated && freebsdFw && agent.agent_version && (
|
{!outdated && fw && agent.agent_version && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||||
Aktuell
|
Aktuell
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
483
frontend/src/pages/ProxmoxServers.jsx
Normal file
483
frontend/src/pages/ProxmoxServers.jsx
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
|
import api from '../api/client'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||||
|
import { Search, ChevronUp, ChevronDown, Plus, Copy, Check, X } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ProxmoxServers() {
|
||||||
|
const [agents, setAgents] = useState([])
|
||||||
|
const [customers, setCustomers] = useState([])
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sortKey, setSortKey] = useState('customer')
|
||||||
|
const [sortDir, setSortDir] = useState('asc')
|
||||||
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||||
|
const [addResult, setAddResult] = useState(null)
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
Promise.all([api.getAgents(), api.getCustomers()])
|
||||||
|
.then(([a, c]) => {
|
||||||
|
setAgents(a || [])
|
||||||
|
setCustomers(c || [])
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload()
|
||||||
|
const iv = setInterval(reload, 30000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const customerMap = useMemo(() => {
|
||||||
|
const m = {}
|
||||||
|
customers.forEach((c) => (m[c.id] = c))
|
||||||
|
return m
|
||||||
|
}, [customers])
|
||||||
|
|
||||||
|
// Enrich agents with system data summaries
|
||||||
|
const [agentDetails, setAgentDetails] = useState({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
agents.forEach((a) => {
|
||||||
|
if (!agentDetails[a.id]) {
|
||||||
|
api.getAgent(a.id).then((d) => {
|
||||||
|
setAgentDetails((prev) => ({ ...prev, [a.id]: d }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [agents])
|
||||||
|
|
||||||
|
const toggleSort = (key) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = useMemo(() => {
|
||||||
|
return agents.map((a) => {
|
||||||
|
const detail = agentDetails[a.id]
|
||||||
|
const sys = detail?.system_data
|
||||||
|
const proxmox = sys?.proxmox
|
||||||
|
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||||
|
|
||||||
|
// Nur Proxmox-Agents anzeigen
|
||||||
|
if (!proxmox?.available) 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)
|
||||||
|
: null
|
||||||
|
const ramPct = sys?.memory?.total_bytes > 0
|
||||||
|
? (sys.memory.used_bytes / sys.memory.total_bytes * 100)
|
||||||
|
: null
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
customer: cust,
|
||||||
|
customerName: cust?.name || '',
|
||||||
|
customerNumber: cust?.number || '',
|
||||||
|
cpuPct: sys?.cpu?.usage_percent ?? null,
|
||||||
|
ramPct,
|
||||||
|
diskPct,
|
||||||
|
uptime: sys?.uptime_seconds || 0,
|
||||||
|
pveVersion: proxmox.version || '',
|
||||||
|
vms: `${vmsRunning}/${vmsTotal}`,
|
||||||
|
vmsRunning,
|
||||||
|
vmsTotal,
|
||||||
|
containers: `${ctRunning}/${ctTotal}`,
|
||||||
|
ctRunning,
|
||||||
|
ctTotal,
|
||||||
|
sys,
|
||||||
|
proxmox,
|
||||||
|
}
|
||||||
|
}).filter(Boolean) // Entferne null Werte
|
||||||
|
}, [agents, agentDetails, customerMap])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = enriched
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
list = list.filter((a) =>
|
||||||
|
a.name.toLowerCase().includes(q) ||
|
||||||
|
a.ip.toLowerCase().includes(q) ||
|
||||||
|
a.hostname.toLowerCase().includes(q) ||
|
||||||
|
a.customerName.toLowerCase().includes(q) ||
|
||||||
|
a.customerNumber.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
list.sort((a, b) => {
|
||||||
|
let va, vb
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'customer': va = a.customerNumber; vb = b.customerNumber; break
|
||||||
|
case 'name': va = a.name; vb = b.name; break
|
||||||
|
case 'ip': va = a.ip; vb = b.ip; break
|
||||||
|
case 'version': va = a.pveVersion; vb = b.pveVersion; break
|
||||||
|
case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime
|
||||||
|
case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1)
|
||||||
|
case 'ram': return sortDir === 'asc' ? (a.ramPct ?? 999) - (b.ramPct ?? 999) : (b.ramPct ?? -1) - (a.ramPct ?? -1)
|
||||||
|
case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1)
|
||||||
|
case 'vms': return sortDir === 'asc' ? a.vmsTotal - b.vmsTotal : b.vmsTotal - a.vmsTotal
|
||||||
|
case 'containers': return sortDir === 'asc' ? a.ctTotal - b.ctTotal : b.ctTotal - a.ctTotal
|
||||||
|
case 'status': va = a.status; vb = b.status; break
|
||||||
|
default: va = a.name; vb = b.name
|
||||||
|
}
|
||||||
|
if (typeof va === 'string') {
|
||||||
|
const cmp = va.localeCompare(vb)
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}, [enriched, search, sortKey, sortDir])
|
||||||
|
|
||||||
|
const SortHeader = ({ label, k, className = '' }) => (
|
||||||
|
<th
|
||||||
|
className={`px-3 py-2 font-medium cursor-pointer hover:text-gray-300 select-none ${className}`}
|
||||||
|
onClick={() => toggleSort(k)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{label}
|
||||||
|
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-white">Proxmox Server ({enriched.length})</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Server hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Suchen (Name, IP, Kunde)..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500">Laden...</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
||||||
|
<th className="px-3 py-2 font-medium w-8"></th>
|
||||||
|
<SortHeader label="KUNDE" k="customer" />
|
||||||
|
<SortHeader label="NAME" k="name" />
|
||||||
|
<th className="px-3 py-2 font-medium">HOSTNAME</th>
|
||||||
|
<SortHeader label="PVE VERSION" k="version" />
|
||||||
|
<th className="px-3 py-2 font-medium">IP</th>
|
||||||
|
<SortHeader label="UPTIME" k="uptime" />
|
||||||
|
<SortHeader label="CPU" k="cpu" />
|
||||||
|
<SortHeader label="RAM" k="ram" />
|
||||||
|
<SortHeader label="DISK" k="disk" />
|
||||||
|
<SortHeader label="VMS" k="vms" />
|
||||||
|
<SortHeader label="CONTAINER" k="containers" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800">
|
||||||
|
{filtered.map((a) => (
|
||||||
|
<tr
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => setSelectedId(a.id)}
|
||||||
|
className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2"><StatusBadge status={a.status} size="dot" /></td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-white font-medium">{a.name}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{a.pveVersion || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{a.uptime ? formatUptime(a.uptime) : '—'}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<MiniBar value={a.cpuPct} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<MiniBar value={a.ramPct} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<MiniBar value={a.diskPct} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<VmBadge running={a.vmsRunning} total={a.vmsTotal} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<VmBadge running={a.ctRunning} total={a.ctTotal} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{search ? 'Keine Treffer' : 'Keine Proxmox Server registriert'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail Panel */}
|
||||||
|
{selectedId && (
|
||||||
|
<ProxmoxPanel
|
||||||
|
agentId={selectedId}
|
||||||
|
customers={customers}
|
||||||
|
onClose={() => setSelectedId(null)}
|
||||||
|
onReload={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server hinzufuegen Dialog */}
|
||||||
|
{showAddDialog && (
|
||||||
|
<AddServerDialog
|
||||||
|
customers={customers}
|
||||||
|
result={addResult}
|
||||||
|
onAdd={async (name, custId) => {
|
||||||
|
const res = await api.preRegisterAgent(name, custId)
|
||||||
|
setAddResult(res)
|
||||||
|
reload()
|
||||||
|
}}
|
||||||
|
onClose={() => { setShowAddDialog(false); setAddResult(null) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) => (
|
||||||
|
<button onClick={() => copyText(text, id)} className="ml-2 text-gray-500 hover:text-orange-400 transition-colors">
|
||||||
|
{copied === id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const installCmd = `# Proxmox/Linux Server Installation:
|
||||||
|
|
||||||
|
# 1. Agent + Updater auf den Server kopieren:
|
||||||
|
scp build/rmm-agent-linux build/rmm-updater-linux root@<SERVER-IP>:/tmp/
|
||||||
|
|
||||||
|
# 2. Installation auf dem Server:
|
||||||
|
ssh root@<SERVER-IP> '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 || '<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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Server hinzufuegen</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
{!result ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => 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()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Kunde (optional)</label>
|
||||||
|
<select
|
||||||
|
value={custId}
|
||||||
|
onChange={e => setCustId(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500"
|
||||||
|
>
|
||||||
|
<option value="">Kein Kunde</option>
|
||||||
|
{customers.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
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...' : 'Server anlegen'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
||||||
|
Server "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-white mb-2">Installationsanleitung</h3>
|
||||||
|
<div className="bg-gray-950 rounded p-3 text-xs font-mono text-gray-300 whitespace-pre-wrap relative">
|
||||||
|
{installCmd}
|
||||||
|
<button
|
||||||
|
onClick={() => copyText(installCmd, 'install')}
|
||||||
|
className="absolute top-2 right-2 text-gray-600 hover:text-orange-400 transition-colors"
|
||||||
|
>
|
||||||
|
{copied === 'install' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Backend URL:</span>
|
||||||
|
<span className="text-white ml-2">{BACKEND_URL}</span>
|
||||||
|
<CopyBtn text={BACKEND_URL} id="url" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">API Key:</span>
|
||||||
|
<span className="text-white ml-2 font-mono text-xs">{API_KEY}</span>
|
||||||
|
<CopyBtn text={API_KEY} id="key" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Der Agent verbindet sich nach der Installation automatisch mit dem Backend.
|
||||||
|
Hostname und IP werden beim ersten Heartbeat aktualisiert.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VmBadge({ running, total }) {
|
||||||
|
if (total === 0) return <span className="text-gray-600 text-xs">—</span>
|
||||||
|
return (
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className="text-green-400">{running}</span>
|
||||||
|
<span className="text-gray-500">/{total}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniBar({ value }) {
|
||||||
|
if (value === null || value === undefined) return <span className="text-gray-600 text-xs">—</span>
|
||||||
|
const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500'
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-12 h-1.5 bg-gray-800 rounded overflow-hidden">
|
||||||
|
<div className={`h-full rounded ${color}`} style={{ width: `${Math.min(value, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">{value.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
@ -77,7 +77,7 @@ export default function Tunnels() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-xl font-bold text-white">Aktive Tunnel</h1>
|
<h1 className="text-xl font-bold text-white">Aktive Tunnel</h1>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{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)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -104,7 +104,7 @@ export default function Tunnels() {
|
|||||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Suchen (Firewall, IP, Ziel, Port)..."
|
placeholder="Suchen (Geraet, IP, Ziel, Port)..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
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 && <div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">{error}</div>}
|
{error && <div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">{error}</div>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-gray-500">Lade Tunnel aller Firewalls...</div>
|
<div className="text-gray-500">Lade Tunnel aller Geraete...</div>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? allTunnels.filter((t) => {
|
? allTunnels.filter((t) => {
|
||||||
@ -131,7 +131,7 @@ export default function Tunnels() {
|
|||||||
<Cable className="w-10 h-10 text-gray-700 mx-auto mb-3" />
|
<Cable className="w-10 h-10 text-gray-700 mx-auto mb-3" />
|
||||||
<div className="text-gray-500">Keine aktiven Tunnel</div>
|
<div className="text-gray-500">Keine aktiven Tunnel</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
Tunnel koennen ueber die Firewall-Detailansicht erstellt werden
|
Tunnel koennen ueber die Geraete-Detailansicht erstellt werden
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -139,7 +139,7 @@ export default function Tunnels() {
|
|||||||
<table className="w-full text-sm whitespace-nowrap">
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
||||||
<th className="px-4 py-2.5 font-medium">Firewall</th>
|
<th className="px-4 py-2.5 font-medium">Geraet</th>
|
||||||
<th className="px-4 py-2.5 font-medium">Ziel</th>
|
<th className="px-4 py-2.5 font-medium">Ziel</th>
|
||||||
<th className="px-4 py-2.5 font-medium">Lokaler Zugang</th>
|
<th className="px-4 py-2.5 font-medium">Lokaler Zugang</th>
|
||||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||||
|
|||||||
103
updater/main.go
103
updater/main.go
@ -11,13 +11,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config wird aus der gleichen config.yaml wie der Agent gelesen
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BackendURL string `yaml:"backend_url"`
|
BackendURL string `yaml:"backend_url"`
|
||||||
APIKey string `yaml:"api_key"`
|
APIKey string `yaml:"api_key"`
|
||||||
@ -25,26 +24,57 @@ type Config struct {
|
|||||||
Insecure bool `yaml:"insecure"`
|
Insecure bool `yaml:"insecure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatResponse struct {
|
// Plattform-spezifische Pfade und Befehle
|
||||||
Message string `json:"message"`
|
var (
|
||||||
UpdateAvailable bool `json:"update_available"`
|
agentBinaryPath string
|
||||||
UpdateVersion string `json:"update_version"`
|
configPath string
|
||||||
UpdateHash string `json:"update_hash"`
|
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 (
|
const (
|
||||||
agentBinaryPath = "/usr/local/rmm/rmm-agent"
|
checkInterval = 60 * time.Second
|
||||||
configPath = "/usr/local/rmm/config.yaml"
|
logPrefix = "[rmm-updater] "
|
||||||
agentIDPath = "/usr/local/rmm/agent_id"
|
|
||||||
checkInterval = 60 * time.Second
|
|
||||||
logPrefix = "[rmm-updater] "
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetPrefix(logPrefix)
|
log.SetPrefix(logPrefix)
|
||||||
log.SetFlags(log.LstdFlags)
|
log.SetFlags(log.LstdFlags)
|
||||||
|
|
||||||
log.Println("RMM Updater gestartet")
|
log.Printf("RMM Updater gestartet (platform=%s, binary=%s)", platformName, agentBinaryPath)
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -80,9 +110,6 @@ func loadConfig() (*Config, error) {
|
|||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, err
|
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
|
cfg.Insecure = true
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@ -100,8 +127,8 @@ func loadAgentID() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkAndUpdate(client *http.Client, cfg *Config, agentID string) {
|
func checkAndUpdate(client *http.Client, cfg *Config, agentID string) {
|
||||||
// Firmware-Info vom Backend holen
|
// Firmware-Info vom Backend holen (plattformspezifisch)
|
||||||
url := fmt.Sprintf("%s/api/v1/firmware", cfg.BackendURL)
|
url := fmt.Sprintf("%s/api/v1/firmware?platform=%s", cfg.BackendURL, platformName)
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
req.Header.Set("X-API-Key", cfg.APIKey)
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return // Keine Firmware vorhanden
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var info struct {
|
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
|
// Pruefen ob lokale Binary schon die richtige Version hat
|
||||||
if localHash, err := hashFile(agentBinaryPath); err == nil && localHash == info.Hash {
|
if localHash, err := hashFile(agentBinaryPath); err == nil && localHash == info.Hash {
|
||||||
return // Schon aktuell
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat-Response pruefen: Update angefordert?
|
// Update angefordert?
|
||||||
hbURL := fmt.Sprintf("%s/api/v1/agents/%s", cfg.BackendURL, agentID)
|
agentURL := fmt.Sprintf("%s/api/v1/agents/%s", cfg.BackendURL, agentID)
|
||||||
hbReq, _ := http.NewRequest("GET", hbURL, nil)
|
agentReq, _ := http.NewRequest("GET", agentURL, nil)
|
||||||
hbReq.Header.Set("X-API-Key", cfg.APIKey)
|
agentReq.Header.Set("X-API-Key", cfg.APIKey)
|
||||||
|
|
||||||
hbResp, err := client.Do(hbReq)
|
agentResp, err := client.Do(agentReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer hbResp.Body.Close()
|
defer agentResp.Body.Close()
|
||||||
|
|
||||||
var agentInfo struct {
|
var agentInfo struct {
|
||||||
Agent struct {
|
Agent struct {
|
||||||
UpdateRequested bool `json:"update_requested"`
|
UpdateRequested bool `json:"update_requested"`
|
||||||
} `json:"agent"`
|
} `json:"agent"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(hbResp.Body).Decode(&agentInfo); err != nil {
|
if err := json.NewDecoder(agentResp.Body).Decode(&agentInfo); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !agentInfo.Agent.UpdateRequested {
|
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 {
|
if err := downloadAndApply(client, cfg, agentID, info.Hash); err != nil {
|
||||||
log.Printf("Update fehlgeschlagen: %v", err)
|
log.Printf("Update fehlgeschlagen: %v", err)
|
||||||
return
|
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 {
|
func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash string) error {
|
||||||
// Download
|
url := fmt.Sprintf("%s/api/v1/firmware/download?platform=%s", cfg.BackendURL, platformName)
|
||||||
url := fmt.Sprintf("%s/api/v1/firmware/download", cfg.BackendURL)
|
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
req.Header.Set("X-API-Key", cfg.APIKey)
|
req.Header.Set("X-API-Key", cfg.APIKey)
|
||||||
|
|
||||||
@ -203,16 +228,15 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st
|
|||||||
|
|
||||||
// Agent stoppen
|
// Agent stoppen
|
||||||
log.Println("Agent wird gestoppt...")
|
log.Println("Agent wird gestoppt...")
|
||||||
exec.Command("/usr/sbin/service", "rmm_agent", "stop").Run()
|
stopAgent()
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// Binary ersetzen
|
// Binary ersetzen mit Rollback
|
||||||
backupPath := agentBinaryPath + ".bak"
|
backupPath := agentBinaryPath + ".bak"
|
||||||
if _, err := os.Stat(agentBinaryPath); err == nil {
|
if _, err := os.Stat(agentBinaryPath); err == nil {
|
||||||
os.Rename(agentBinaryPath, backupPath)
|
os.Rename(agentBinaryPath, backupPath)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpPath, agentBinaryPath); err != nil {
|
if err := os.Rename(tmpPath, agentBinaryPath); err != nil {
|
||||||
// Rollback
|
|
||||||
os.Rename(backupPath, agentBinaryPath)
|
os.Rename(backupPath, agentBinaryPath)
|
||||||
return fmt.Errorf("binary ersetzen fehlgeschlagen: %v", err)
|
return fmt.Errorf("binary ersetzen fehlgeschlagen: %v", err)
|
||||||
}
|
}
|
||||||
@ -220,10 +244,10 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st
|
|||||||
|
|
||||||
// Agent starten
|
// Agent starten
|
||||||
log.Println("Agent wird gestartet...")
|
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)
|
log.Printf("WARNUNG: Agent-Start fehlgeschlagen: %v — versuche Rollback", err)
|
||||||
os.Rename(backupPath, agentBinaryPath)
|
os.Rename(backupPath, agentBinaryPath)
|
||||||
exec.Command("/usr/sbin/service", "rmm_agent", "start").Run()
|
startAgent()
|
||||||
return fmt.Errorf("agent start fehlgeschlagen")
|
return fmt.Errorf("agent start fehlgeschlagen")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,8 +271,3 @@ func hashFile(path string) (string, error) {
|
|||||||
hash := sha256.Sum256(data)
|
hash := sha256.Sum256(data)
|
||||||
return hex.EncodeToString(hash[:]), nil
|
return hex.EncodeToString(hash[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveDir gibt das Verzeichnis eines Pfads zurueck
|
|
||||||
func resolveDir(path string) string {
|
|
||||||
return filepath.Dir(path)
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user