From e74cdce0ba92138248a692994f992e7168d92161 Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Sat, 28 Feb 2026 22:43:03 +0100 Subject: [PATCH] Agent-Updater, Multi-Plattform Firmware, Frontend Firmware-Seite, Agent-Tab, Zertifikate-Redesign Updater: - Separater Go-Daemon (rmm-updater) als eigener rc.d Service - Pollt alle 60s: Firmware vorhanden + Hash unterschiedlich + Flag gesetzt - Download -> SHA256 Verify -> Agent Stop -> Replace -> Agent Start - Automatisches Rollback bei Fehler - Deployed und getestet auf allen 3 Firewalls (.1, .19, .33) Backend: - Firmware-DB multi-plattform (freebsd/linux/windows) - update_requested Flag pro Agent in agents Tabelle - Heartbeat-Response liefert update_available wenn Flag gesetzt - Neue Endpoints: request-update, request-update-all, firmware/download - Auth: JWT + CombinedAuth Middleware (API-Key OR JWT) - Customers CRUD + Agent-Zuordnung - Users CRUD + Passwort-Aenderung Frontend: - Firmware-Seite: Multi-Plattform Upload, Agent-Liste mit Update-Trigger - Agent-Tab im AgentPanel: Update-Button, nuetzliche Befehle, Agent-Info - Zertifikate-Tab redesigned: Karten-Layout mit Restlaufzeit und Farbbalken - Sidebar: Firmware-Eintrag hinzugefuegt Plugin: - install.sh erweitert: Installiert Agent + Updater + rc.d Services - rmm_updater rc.d Service README: - Architektur-Diagramm erweitert (Frontend + Updater) - Auth, Customers, Users, Firmware API dokumentiert - Updater-Flow dokumentiert - Frontend-Seiten und Deploy beschrieben - DB-Schema aktualisiert (neue Tabellen) - Dateistruktur erweitert (updater/, frontend/, firmware.go, auth.go) --- Makefile | 12 +- README.md | 179 ++- agent/collector/opnsense.go | 4 +- agent/main.go | 8 +- agent/ws/client.go | 35 +- agent/ws/handler.go | 85 ++ backend/api/firmware.go | 219 ++++ backend/api/handlers.go | 33 +- backend/db/postgres.go | 129 +- backend/models/agent.go | 3 + backend/models/system.go | 10 +- frontend/src/App.jsx | 4 + frontend/src/api/client.js | 61 +- frontend/src/components/AgentPanel.jsx | 1109 ++++++++++++++--- frontend/src/layouts/AppLayout.jsx | 4 + frontend/src/pages/AgentDetail.jsx | 98 +- frontend/src/pages/Agents.jsx | 9 +- frontend/src/pages/Dashboard.jsx | 33 +- frontend/src/pages/Firmware.jsx | 272 ++++ frontend/src/pages/SettingsPage.jsx | 148 ++- frontend/src/pages/Tunnels.jsx | 221 ++++ opnsense-plugin/install.sh | 97 +- .../src/usr/local/etc/rc.d/rmm_updater | 62 + updater/go.mod | 5 + updater/go.sum | 4 + updater/main.go | 254 ++++ 26 files changed, 2857 insertions(+), 241 deletions(-) create mode 100644 backend/api/firmware.go create mode 100644 frontend/src/pages/Firmware.jsx create mode 100644 frontend/src/pages/Tunnels.jsx create mode 100644 opnsense-plugin/src/usr/local/etc/rc.d/rmm_updater create mode 100644 updater/go.mod create mode 100644 updater/go.sum create mode 100644 updater/main.go diff --git a/Makefile b/Makefile index a92a758..7da69a9 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ -.PHONY: all backend agent plugin clean certs deploy-backend deploy-agent +.PHONY: all backend agent updater plugin clean certs deploy-backend deploy-agent BACKEND_BIN = build/rmm-backend AGENT_BIN = build/rmm-agent +UPDATER_BIN = build/rmm-updater BACKEND_HOST = 192.168.85.13 AGENT_HOST = 192.168.85.33 SSH_USER = root -all: backend agent +all: backend agent updater backend: @echo "==> Building Backend (linux/amd64)..." @@ -16,9 +17,14 @@ backend: agent: @echo "==> Building Agent (freebsd/amd64)..." - cd agent && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -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" +updater: + @echo "==> Building Updater (freebsd/amd64)..." + cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_BIN) . + @echo "==> $(UPDATER_BIN) erstellt" + plugin: @echo "==> Building OPNsense Plugin Package..." cd opnsense-plugin && ./build.sh $(VERSION) diff --git a/README.md b/README.md index b2c8586..09b8b85 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ Go-basierter Agent + Backend mit WebSocket-Kommunikation und Session-basierten T │ OPNsense FW │◄──────────────►│ RMM Backend │◄───────────────│ Browser/ │ │ (FreeBSD) │ :8443 │ (Linux) │ :10000-20000 │ SSH/etc. │ │ │ │ │ └────────────┘ -│ rmm-agent │ Heartbeat │ REST API │ -│ - Collectors │ WebSocket │ WebSocket Hub │ -│ - WS Client │ Binary Msgs │ Tunnel Mgr │ -│ - Tunnel Mgr │ │ PostgreSQL + │ -│ │ │ TimescaleDB │ +│ rmm-agent │ Heartbeat │ REST API │ ┌────────────┐ +│ rmm-updater │ WebSocket │ WebSocket Hub │◄───────────────│ Frontend │ +│ - Collectors │ Binary Msgs │ Tunnel Mgr │ Nginx Proxy │ (React) │ +│ - WS Client │ │ PostgreSQL + │ :80/443 │ .20 │ +│ - Tunnel Mgr │ │ TimescaleDB │ └────────────┘ └────────────────┘ └────────────────┘ ``` @@ -67,6 +67,7 @@ Client ──► Backend:ProxyPort ──► WebSocket (Binary) ──► Agent | `wg_delete_peer` | `DELETE /agents/{id}/wireguard/peers/{uuid}` | WireGuard-Peer loeschen | | `tunnel_connect` | `POST /agents/{id}/tunnel` | TCP-Tunnel zu lokalem Dienst oeffnen | | `tunnel_disconnect` | `DELETE /agents/{id}/tunnel/{tid}` | Tunnel schliessen | +| `agent_update` | `POST /agents/{id}/agent-update` | Agent-Binary remote aktualisieren (via WebSocket, Legacy) | ### Registrierte Agents @@ -241,10 +242,13 @@ Das Backend erstellt folgendes Schema bei erstem Start: | Tabelle | Typ | Beschreibung | |---------|-----|-------------| -| `agents` | Relational | Agent-Registry (ID, Name, Hostname, IP, Version, Heartbeat) | +| `agents` | Relational | Agent-Registry (ID, Name, Hostname, IP, Version, Heartbeat, customer_id, update_requested) | | `system_data` | Relational | Letzte Systemdaten pro Agent (JSONB) | | `config_backups` | Relational | OPNsense Config-Backups (dedupliziert per SHA256) | | `agent_events` | Relational | Online/Offline/Connected Events | +| `customers` | Relational | Kundenstammdaten (Nummer, Name) | +| `users` | Relational | Benutzer (Username, bcrypt-Hash, Display-Name) | +| `agent_firmware` | Relational | Agent-Binaries pro Plattform+Version (BYTEA, SHA256) | | `metrics` | TimescaleDB Hypertable | Time-Series Metriken (CPU, RAM, Disk, Network, Gateway) | TimescaleDB Policies: @@ -307,9 +311,27 @@ ssh root@OPNSENSE 'service rmm_agent status' curl -sk -H "X-API-Key: DEIN_API_KEY" https://BACKEND_IP:8443/api/v1/agents ``` -### Agent-Update +### Agent-Update (via Updater — empfohlen) -Bei neuer Agent-Version: +Der **rmm-updater** ist ein separater Daemon, der auf jeder Firewall neben dem Agent laeuft. +Er prueft alle 60 Sekunden ob ein Update ansteht und fuehrt es automatisch durch. + +**Flow:** +1. Neue Agent-Binary im Frontend hochladen (Firmware-Seite) +2. Update-Button bei einzelnem Agent oder "Alle updaten" klicken +3. Updater erkennt den Flag, laedt die Binary vom Backend, stoppt Agent, ersetzt Binary, startet Agent +4. Flag wird automatisch zurueckgesetzt + +**Technische Details:** +- Separates Go-Binary (`rmm-updater`), laeuft als `rmm_updater` rc.d Service +- Liest die gleiche `config.yaml` wie der Agent (Backend-URL, API-Key) +- Download ueber `GET /api/v1/firmware/download?platform=freebsd` +- SHA256-Verifizierung vor Ersetzung +- Automatisches Rollback bei fehlgeschlagenem Agent-Start +- Backup der alten Binary als `.bak` waehrend des Updates +- Logs: `/var/log/rmm-updater.log` + +**Manuelles Update (Fallback):** ```bash make agent @@ -317,7 +339,7 @@ scp build/rmm-agent root@OPNSENSE:/tmp/ ssh root@OPNSENSE '/bin/sh -c "service rmm_agent stop; cp /tmp/rmm-agent /usr/local/rmm/rmm-agent; chmod +x /usr/local/rmm/rmm-agent; service rmm_agent start"' ``` -Oder Plugin komplett neu installieren (`install.sh` ist idempotent). +Oder Plugin komplett neu installieren (`install.sh` ist idempotent — installiert Agent + Updater). ### Hinweise @@ -352,6 +374,77 @@ Status wird automatisch aus `last_heartbeat` berechnet (kein DB-Feld): `GET /agents` liefert ein `status` Feld pro Agent, `GET /agents/{id}` ebenfalls. +### Authentifizierung + +Das Backend unterstuetzt zwei Auth-Methoden (CombinedAuth Middleware): +- **API-Key** (Header `X-API-Key`): Fuer Agents und automatisierte Zugriffe +- **JWT Bearer Token** (Header `Authorization: Bearer `): Fuer Frontend-Benutzer + +| Methode | Endpoint | Beschreibung | +|---------|----------|-------------| +| POST | `/api/v1/auth/login` | Login (username + password → JWT Token, 8h Ablauf) | +| GET | `/api/v1/auth/me` | Eigene Benutzerdaten | + +Default-Admin wird automatisch beim ersten Start erstellt: `admin` / `Start!123` (min. 6 Zeichen). + +### Kunden (Customers) + +| Methode | Endpoint | Beschreibung | +|---------|----------|-------------| +| GET | `/api/v1/customers` | Alle Kunden auflisten | +| POST | `/api/v1/customers` | Neuen Kunden anlegen (`{number, name}`) | +| GET | `/api/v1/customers/{id}` | Einzelnen Kunden abrufen | +| PUT | `/api/v1/customers/{id}` | Kunden aktualisieren | +| DELETE | `/api/v1/customers/{id}` | Kunden loeschen | +| GET | `/api/v1/customers/{id}/agents` | Agents eines Kunden | +| PUT | `/api/v1/agents/{id}/customer` | Agent einem Kunden zuordnen | +| DELETE | `/api/v1/agents/{id}/customer` | Kundenzuordnung entfernen | + +### Benutzer (Users) + +| Methode | Endpoint | Beschreibung | +|---------|----------|-------------| +| GET | `/api/v1/users` | Alle Benutzer auflisten | +| 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 | + +### Firmware / Agent-Update + +Multi-Plattform Firmware-Verwaltung (freebsd, linux, windows): + +| Methode | Endpoint | Beschreibung | +|---------|----------|-------------| +| GET | `/api/v1/firmware` | Alle Firmware-Versionen (pro Plattform) | +| GET | `/api/v1/firmware?platform=freebsd` | Firmware einer Plattform | +| POST | `/api/v1/firmware/upload?version=X&platform=Y` | Binary hochladen (Body = Raw Binary) | +| GET | `/api/v1/firmware/download?platform=Y` | Binary herunterladen (fuer Updater) | +| POST | `/api/v1/agents/{id}/request-update` | Update-Flag setzen (Updater fuehrt aus) | +| DELETE | `/api/v1/agents/{id}/request-update` | Update-Flag zuruecksetzen | +| POST | `/api/v1/agents/request-update-all` | Update-Flag fuer alle Agents setzen | +| POST | `/api/v1/agents/{id}/agent-update` | Legacy: Binary direkt via WebSocket pushen | + +**Firmware-Upload Beispiel:** +```bash +# FreeBSD Agent hochladen +curl -sk -X POST "https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.2&platform=freebsd" \ + -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + --data-binary @build/rmm-agent + +# Linux Agent hochladen +curl -sk -X POST "https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.2&platform=linux" \ + -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + --data-binary @build/rmm-agent-linux + +# Update fuer einzelnen Agent anfordern +curl -sk -X POST "https://192.168.85.13:8443/api/v1/agents/e92e87a6.../request-update" \ + -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" + +# Update fuer alle Agents anfordern +curl -sk -X POST "https://192.168.85.13:8443/api/v1/agents/request-update-all" \ + -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" +``` + ### Agent-Events | Methode | Endpoint | Beschreibung | @@ -691,6 +784,37 @@ Format: `[session_id_length:1][session_id][tcp_payload]` Tunnel-Daten werden als Binary WebSocket Messages uebertragen (kein Base64-Overhead). +## Frontend + +React + Vite + TailwindCSS Web-Frontend, deployed als statische Dateien auf Nginx. + +- **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) + +### Seiten + +| Seite | Beschreibung | +|-------|-------------| +| Dashboard | Uebersicht aller Firewalls, Status-Kacheln, AgentPanel per Klick | +| Firewalls | Suchbare Tabelle, AgentPanel mit Tabs (Uebersicht, Interfaces, Dienste, Tunnel, VPN, WireGuard, Routen, DHCP, Zertifikate, Backups, Agent) | +| Tunnel | Globale Tunnel-Uebersicht ueber alle Firewalls | +| Firmware | Multi-Plattform Firmware-Upload, Agent-Update-Trigger | +| Kunden | CRUD Kundenverwaltung | +| Einstellungen | Benutzerverwaltung, Passworte aendern | + +### Build & Deploy + +```bash +cd frontend +npm run build # ~330 KB JS, ~32 KB CSS +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 und Browser-Cache liefert 404. + ## Dateistruktur ``` @@ -739,14 +863,45 @@ rmm/ │ │ ├── handlers.go # REST API Handler (inkl. Agent-Status + Events) │ │ ├── tunnel_proxy.go # Tunnel + Exec + Update REST Endpoints │ │ ├── backup.go # Config-Backup Endpoints (trigger, list, download, diff) -│ │ ├── wireguard.go # WireGuard Peer API Endpoints +│ │ ├── wireguard.go # WireGuard Peer API Endpoints +│ │ ├── firmware.go # Firmware Upload/Download/Info, Update-Request +│ │ ├── auth.go # Login, JWT Token, /me Endpoint +│ │ ├── customers.go # Kunden CRUD + Agent-Zuordnung +│ │ ├── users.go # Benutzer CRUD + Passwort-Aenderung │ │ ├── metrics.go # Metriken-Abfrage Endpoints (raw + summary) -│ │ └── middleware.go # API-Key Auth, Logging +│ │ └── middleware.go # CombinedAuth (API-Key OR JWT), CORS, Logging │ └── ws/ │ ├── handler.go # WebSocket Upgrade, Connection R/W Pumps │ ├── hub.go # Agent-Registry, Message Routing │ └── tunnel.go # Session-basierter Tunnel + Proxy Manager -└── build/ # Kompilierte Binaries +├── updater/ +│ ├── main.go # Separater Update-Daemon (pollt Firmware, ersetzt Agent-Binary) +│ └── go.mod +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Router + ProtectedRoute +│ │ ├── api/client.js # API Client (auth, agents, firmware, tunnels, etc.) +│ │ ├── stores/auth.js # Zustand Auth Store (JWT Token) +│ │ ├── layouts/AppLayout.jsx # Sidebar + Outlet +│ │ ├── components/ +│ │ │ ├── AgentPanel.jsx # Side Panel mit Tabs (Uebersicht bis Agent) +│ │ │ └── StatusBadge.jsx # Online/Offline/Stale Badge +│ │ └── pages/ +│ │ ├── Dashboard.jsx # Status-Kacheln + Agent-Liste +│ │ ├── Agents.jsx # Firewall-Tabelle +│ │ ├── AgentDetail.jsx # Detail-Ansicht (full page) +│ │ ├── Tunnels.jsx # Globale Tunnel-Uebersicht +│ │ ├── Firmware.jsx # Multi-Plattform Firmware + Update-Trigger +│ │ ├── Customers.jsx # Kunden CRUD +│ │ ├── SettingsPage.jsx # User-Management +│ │ └── Login.jsx # JWT Login +│ ├── vite.config.js +│ └── package.json +├── opnsense-plugin/ +│ ├── install.sh # Self-contained Installer (Agent + Updater + MVC Plugin) +│ ├── build.sh +│ └── src/ # MVC Model, Controllers, Views, configd, rc.d +└── build/ # Kompilierte Binaries (rmm-backend, rmm-agent, rmm-updater) ``` ## Technische Details diff --git a/agent/collector/opnsense.go b/agent/collector/opnsense.go index ab97f14..4c4dca8 100644 --- a/agent/collector/opnsense.go +++ b/agent/collector/opnsense.go @@ -8,7 +8,7 @@ import ( ) func CollectOPNsenseVersion() string { - out, err := exec.Command("opnsense-version").Output() + out, err := exec.Command("/usr/local/sbin/opnsense-version").Output() if err != nil { return "" } @@ -18,7 +18,7 @@ func CollectOPNsenseVersion() string { } func CollectFreeBSDVersion() string { - out, err := exec.Command("freebsd-version").Output() + out, err := exec.Command("/bin/freebsd-version").Output() if err != nil { return sysctlGet("kern.osrelease") } diff --git a/agent/main.go b/agent/main.go index 5b489e2..b217694 100644 --- a/agent/main.go +++ b/agent/main.go @@ -15,6 +15,8 @@ import ( "github.com/cynfo/rmm-agent/ws" ) +var Version = "dev" + func main() { cfgPath := flag.String("config", "config.yaml", "Pfad zur Konfigurationsdatei") insecure := flag.Bool("insecure", false, "TLS-Zertifikatpruefung deaktivieren") @@ -29,7 +31,7 @@ func main() { cfg.Insecure = true } - log.Printf("RMM Agent startet: %s", cfg.AgentName) + log.Printf("RMM Agent 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) @@ -57,6 +59,7 @@ func main() { "hostname": hostname, "ip": getLocalIP(), "opnsense_version": collector.CollectOPNsenseVersion(), + "agent_version": Version, } if agentID != "" { regReq["agent_id"] = agentID @@ -124,7 +127,8 @@ func sendHeartbeat(c *client.Client, agentID string) error { cronJobs := collector.CollectCron() req := map[string]interface{}{ - "agent_id": agentID, + "agent_id": agentID, + "agent_version": Version, "system_data": map[string]interface{}{ "agent_id": agentID, "hostname": collector.CollectHostname(), diff --git a/agent/ws/client.go b/agent/ws/client.go index c0416e5..f1ab8c5 100644 --- a/agent/ws/client.go +++ b/agent/ws/client.go @@ -99,7 +99,6 @@ func (c *Client) reconnectLoop() { select { case <-time.After(backoff): - // Exponential backoff mit Maximum backoff = time.Duration(float64(backoff) * 1.5) if backoff > maxBackoff { backoff = maxBackoff @@ -110,14 +109,38 @@ func (c *Client) reconnectLoop() { continue } - // Verbindung erfolgreich - backoff zurücksetzen - backoff = c.reconnectInterval + connStart := time.Now() log.Println("WebSocket-Verbindung hergestellt") // Ping/Pong und Message-Handling starten c.runConnection() - log.Println("WebSocket-Verbindung getrennt") + 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 + } } } @@ -206,9 +229,7 @@ func (c *Client) runConnection() { go c.handleMessage(msg) case err := <-errChan: - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - log.Printf("WebSocket-Fehler: %v", err) - } + log.Printf("WebSocket-Lesefehler: %v", err) return } } diff --git a/agent/ws/handler.go b/agent/ws/handler.go index 23cc0ef..35d9f58 100644 --- a/agent/ws/handler.go +++ b/agent/ws/handler.go @@ -58,6 +58,8 @@ func (h *Handler) HandleCommand(msg Message) { response.Data = map[string]interface{}{"message": "tunnel_open deprecated, use tunnel_connect"} case "tunnel_close": response.Status = "ok" + case "agent_update": + response = h.handleAgentUpdate(msg) default: response.Status = "error" response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command) @@ -558,6 +560,89 @@ func (h *Handler) parsePkgUpgrade(output string) []map[string]string { return packages } +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/rmm/rmm-agent" + 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) + exec.Command("/usr/sbin/service", "rmm_agent", "restart").Run() + }() + + return response +} + // SendSessionData schickt Tunnel-Daten als Binary-Message zurueck zum Backend func (h *Handler) SendSessionData(sessionID string, data []byte) error { idBytes := []byte(sessionID) diff --git a/backend/api/firmware.go b/backend/api/firmware.go new file mode 100644 index 0000000..eb81796 --- /dev/null +++ b/backend/api/firmware.go @@ -0,0 +1,219 @@ +package api + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/cynfo/rmm-backend/ws" +) + +// POST /api/v1/firmware/upload — Neues Agent-Binary hochladen +func (h *Handler) uploadFirmware(w http.ResponseWriter, r *http.Request) { + version := r.URL.Query().Get("version") + if version == "" { + writeError(w, http.StatusBadRequest, "version Parameter erforderlich") + return + } + + platform := r.URL.Query().Get("platform") + if platform == "" { + platform = "freebsd" // Default fuer OPNsense + } + // Validieren + validPlatforms := map[string]bool{"freebsd": true, "linux": true, "windows": true} + if !validPlatforms[platform] { + writeError(w, http.StatusBadRequest, "Ungueltige Plattform (freebsd, linux, windows)") + return + } + + // Binary aus Body lesen (max 50MB) + r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024) + binary, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "Binary lesen fehlgeschlagen") + return + } + + if len(binary) == 0 { + writeError(w, http.StatusBadRequest, "Leeres Binary") + return + } + + hash := sha256.Sum256(binary) + hashStr := hex.EncodeToString(hash[:]) + + if err := h.db.SaveAgentFirmware(version, platform, hashStr, binary); err != nil { + log.Printf("Firmware speichern fehlgeschlagen: %v", err) + writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") + return + } + + log.Printf("Agent-Firmware v%s (%s) hochgeladen (%d bytes, Hash: %s)", version, platform, len(binary), hashStr[:16]) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "version": version, + "platform": platform, + "hash": hashStr, + "size": len(binary), + "message": "Firmware hochgeladen", + }) +} + +// GET /api/v1/firmware — Firmware-Info (alle Plattformen oder spezifisch) +func (h *Handler) getFirmwareInfo(w http.ResponseWriter, r *http.Request) { + platform := r.URL.Query().Get("platform") + + if platform == "" { + // Alle Plattformen auflisten + all, err := h.db.GetAllFirmwareInfo() + if err != nil || len(all) == 0 { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "available": false, + "message": "Keine Firmware hochgeladen", + "platforms": []interface{}{}, + }) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "available": true, + "platforms": all, + }) + return + } + + version, hash, size, err := h.db.GetFirmwareInfo(platform) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "available": false, + "platform": platform, + "message": "Keine Firmware fuer diese Plattform", + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "available": true, + "platform": platform, + "version": version, + "hash": hash, + "size": size, + }) +} + +// GET /api/v1/firmware/download — Binary herunterladen (fuer Updater) +func (h *Handler) downloadFirmware(w http.ResponseWriter, r *http.Request) { + platform := r.URL.Query().Get("platform") + if platform == "" { + platform = "freebsd" + } + version, hash, binary, err := h.db.GetLatestFirmware(platform) + if err != nil { + writeError(w, http.StatusNotFound, "Keine Firmware verfuegbar") + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=rmm-agent") + w.Header().Set("X-Firmware-Version", version) + w.Header().Set("X-Firmware-Hash", hash) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(binary))) + w.Write(binary) +} + +// POST /api/v1/agents/{id}/request-update — Update-Flag setzen +func (h *Handler) requestUpdate(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + if err := h.db.SetUpdateRequest(agentID, true); err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Setzen des Update-Flags") + return + } + log.Printf("Update angefordert fuer Agent %s", agentID) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Update angefordert", "agent_id": agentID}) +} + +// DELETE /api/v1/agents/{id}/request-update — Update-Flag loeschen +func (h *Handler) cancelUpdate(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + h.db.ClearUpdateRequest(agentID) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Update-Anforderung geloescht", "agent_id": agentID}) +} + +// POST /api/v1/agents/request-update-all — Update-Flag fuer alle setzen +func (h *Handler) requestUpdateAll(w http.ResponseWriter, r *http.Request) { + count, err := h.db.SetUpdateRequestAll(true) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler") + return + } + log.Printf("Update angefordert fuer %d Agents", count) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": fmt.Sprintf("Update fuer %d Agents angefordert", count), "count": count}) +} + +// POST /api/v1/agents/{id}/agent-update — Update an einzelnen Agent pushen +func (h *Handler) pushAgentUpdate(hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + + // TODO: Agent-Plattform aus DB lesen; fuer jetzt default freebsd + version, hash, binary, err := h.db.GetLatestFirmware("freebsd") + if err != nil { + writeError(w, http.StatusNotFound, "Keine Firmware verfuegbar") + return + } + + // Agent-Version pruefen + agent, _, err := h.db.GetAgent(agentID) + if err != nil || agent == nil { + writeError(w, http.StatusNotFound, "Agent nicht gefunden") + return + } + + if agent.AgentVersion == version { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Agent ist bereits auf dem neuesten Stand", + "current_version": agent.AgentVersion, + "target_version": version, + "updated": false, + }) + return + } + + // Binary als Base64 an Agent senden + binaryB64 := base64.StdEncoding.EncodeToString(binary) + + cmdID := fmt.Sprintf("agent-update-%s", agentID[:8]) + cmd := map[string]interface{}{ + "type": "command", + "id": cmdID, + "command": "agent_update", + "data": map[string]interface{}{ + "binary": binaryB64, + "hash": hash, + "version": version, + "old_version": agent.AgentVersion, + }, + } + + cmdJSON, _ := json.Marshal(cmd) + if err := hub.SendToAgent(agentID, cmdJSON); err != nil { + writeError(w, http.StatusBadGateway, fmt.Sprintf("Agent nicht erreichbar: %v", err)) + return + } + + log.Printf("Agent-Update v%s an %s gesendet (%d bytes)", version, agent.Name, len(binary)) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": fmt.Sprintf("Update v%s an %s gesendet", version, agent.Name), + "current_version": agent.AgentVersion, + "target_version": version, + "updated": true, + "size": len(binary), + }) + } +} diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 89eade8..6d171b8 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -53,6 +53,15 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("PUT /api/v1/users/{id}/password", h.changePassword) mux.HandleFunc("DELETE /api/v1/users/{id}", h.deleteUser) + // Firmware/Agent-Update Routes + mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo) + mux.HandleFunc("POST /api/v1/firmware/upload", h.uploadFirmware) + mux.HandleFunc("GET /api/v1/firmware/download", h.downloadFirmware) + mux.HandleFunc("POST /api/v1/agents/{id}/agent-update", h.pushAgentUpdate(hub)) + mux.HandleFunc("POST /api/v1/agents/{id}/request-update", h.requestUpdate) + mux.HandleFunc("DELETE /api/v1/agents/{id}/request-update", h.cancelUpdate) + mux.HandleFunc("POST /api/v1/agents/request-update-all", h.requestUpdateAll) + // WebSocket und Tunnel-Routes h.setupTunnelRoutes(mux, hub) } @@ -115,6 +124,7 @@ func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) { Hostname: req.Hostname, IP: req.IP, OPNsenseVersion: req.OPNsenseVersion, + AgentVersion: req.AgentVersion, RegisteredAt: time.Now().UTC(), } @@ -148,6 +158,11 @@ func (h *Handler) heartbeat(w http.ResponseWriter, r *http.Request) { return } + // Agent-Version aktualisieren + if req.AgentVersion != "" { + h.db.UpdateAgentVersion(req.AgentID, req.AgentVersion) + } + if err := h.db.SaveSystemData(req.AgentID, &req.SystemData); err != nil { if strings.Contains(err.Error(), "nicht gefunden") { writeError(w, http.StatusNotFound, "Agent nicht gefunden") @@ -158,7 +173,23 @@ func (h *Handler) heartbeat(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, models.HeartbeatResponse{Message: "OK"}) + resp := models.HeartbeatResponse{Message: "OK"} + + // Pruefen ob Update ansteht (default freebsd fuer OPNsense) + if h.db.IsUpdateRequested(req.AgentID) { + if version, hash, _, err := h.db.GetFirmwareInfo("freebsd"); err == nil { + if version != req.AgentVersion { + resp.UpdateAvailable = true + resp.UpdateVersion = version + resp.UpdateHash = hash + } else { + // Gleiche Version — Flag zuruecksetzen + h.db.ClearUpdateRequest(req.AgentID) + } + } + } + + writeJSON(w, http.StatusOK, resp) } // GET /api/v1/agents diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 62c6226..9af285b 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -67,6 +67,7 @@ func (d *Database) migrate() error { hostname TEXT NOT NULL, ip TEXT NOT NULL, opnsense_version TEXT NOT NULL DEFAULT '', + agent_version TEXT NOT NULL DEFAULT '', registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_heartbeat TIMESTAMPTZ ); @@ -124,6 +125,27 @@ func (d *Database) migrate() error { d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id)") d.db.Exec("CREATE INDEX IF NOT EXISTS idx_agents_customer ON agents(customer_id)") + // Agents um agent_version und update_requested erweitern + d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS agent_version TEXT NOT NULL DEFAULT ''") + d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE") + + // Agent-Firmware Tabelle (Multi-Plattform) + d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware ( + version TEXT PRIMARY KEY, + hash TEXT NOT NULL, + binary_data BYTEA NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`) + // Platform-Spalte hinzufuegen (default freebsd fuer bestehende Eintraege) + d.db.Exec("ALTER TABLE agent_firmware DROP CONSTRAINT IF EXISTS agent_firmware_pkey") + d.db.Exec("ALTER TABLE agent_firmware ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'freebsd'") + // Neuer Primary Key: version + platform + d.db.Exec(`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_firmware_version_platform_key') THEN + ALTER TABLE agent_firmware ADD CONSTRAINT agent_firmware_version_platform_key UNIQUE (version, platform); + END IF; + END $$`) + // Metrics Hypertable metricsSchema := ` CREATE TABLE IF NOT EXISTS metrics ( @@ -220,14 +242,15 @@ func (d *Database) Close() error { func (d *Database) RegisterAgent(agent *models.Agent) error { _, err := d.db.Exec(` - INSERT INTO agents (id, name, hostname, ip, opnsense_version, registered_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO agents (id, name, hostname, ip, opnsense_version, agent_version, registered_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name, hostname=EXCLUDED.hostname, ip=EXCLUDED.ip, - opnsense_version=EXCLUDED.opnsense_version - `, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.RegisteredAt.UTC()) + opnsense_version=EXCLUDED.opnsense_version, + agent_version=EXCLUDED.agent_version + `, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.AgentVersion, agent.RegisteredAt.UTC()) return err } @@ -342,7 +365,7 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data * } func (d *Database) GetAgents() ([]models.Agent, error) { - rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents ORDER BY name") + 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") if err != nil { return nil, err } @@ -354,7 +377,7 @@ func (d *Database) GetAgents() ([]models.Agent, error) { var lastHB sql.NullTime var custID sql.NullInt64 - if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested); err != nil { return nil, err } if lastHB.Valid { @@ -399,9 +422,9 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error var custID sql.NullInt64 err := d.db.QueryRow( - "SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents WHERE id = $1", + "SELECT id, name, hostname, ip, opnsense_version, agent_version, registered_at, last_heartbeat, customer_id, update_requested FROM agents WHERE id = $1", id, - ).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID) + ).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested) if err == sql.ErrNoRows { return nil, nil, nil @@ -452,6 +475,96 @@ func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) { return &data, nil } +func (d *Database) UpdateAgentVersion(id, version string) error { + _, err := d.db.Exec("UPDATE agents SET agent_version = $1 WHERE id = $2", version, id) + return err +} + +// Update-Request Flag setzen/lesen/loeschen +func (d *Database) SetUpdateRequest(id string, requested bool) error { + _, err := d.db.Exec("UPDATE agents SET update_requested = $1 WHERE id = $2", requested, id) + return err +} + +func (d *Database) IsUpdateRequested(id string) bool { + var requested bool + err := d.db.QueryRow("SELECT update_requested FROM agents WHERE id = $1", id).Scan(&requested) + return err == nil && requested +} + +func (d *Database) ClearUpdateRequest(id string) error { + return d.SetUpdateRequest(id, false) +} + +// Alle Agents mit Update-Flag setzen +func (d *Database) SetUpdateRequestAll(requested bool) (int64, error) { + res, err := d.db.Exec("UPDATE agents SET update_requested = $1", requested) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// Agent-Firmware speichern (pro Plattform) +func (d *Database) SaveAgentFirmware(version, platform, hash string, binary []byte) error { + _, err := d.db.Exec(` + INSERT INTO agent_firmware (version, platform, hash, binary_data, uploaded_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT(version, platform) DO UPDATE SET hash=EXCLUDED.hash, binary_data=EXCLUDED.binary_data, uploaded_at=NOW() + `, version, platform, hash, binary) + return err +} + +func (d *Database) GetLatestFirmware(platform string) (string, string, []byte, error) { + var version, hash string + var binary []byte + err := d.db.QueryRow("SELECT version, hash, binary_data FROM agent_firmware WHERE platform = $1 ORDER BY uploaded_at DESC LIMIT 1", platform).Scan(&version, &hash, &binary) + if err != nil { + return "", "", nil, err + } + return version, hash, binary, nil +} + +func (d *Database) GetFirmwareInfo(platform string) (string, string, int, error) { + var version, hash string + var size int + err := d.db.QueryRow("SELECT version, hash, length(binary_data) FROM agent_firmware WHERE platform = $1 ORDER BY uploaded_at DESC LIMIT 1", platform).Scan(&version, &hash, &size) + if err != nil { + return "", "", 0, err + } + return version, hash, size, nil +} + +// Alle Plattform-Firmwares auflisten +func (d *Database) GetAllFirmwareInfo() ([]map[string]interface{}, error) { + rows, err := d.db.Query(` + SELECT DISTINCT ON (platform) platform, version, hash, length(binary_data) as size, uploaded_at + FROM agent_firmware ORDER BY platform, uploaded_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []map[string]interface{} + for rows.Next() { + var platform, version, hash string + var size int + var uploadedAt time.Time + if err := rows.Scan(&platform, &version, &hash, &size, &uploadedAt); err != nil { + return nil, err + } + results = append(results, map[string]interface{}{ + "platform": platform, + "version": version, + "hash": hash, + "size": size, + "uploaded_at": uploadedAt, + }) + } + return results, nil +} + func (d *Database) DeleteAgent(id string) (bool, error) { res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id) if err != nil { diff --git a/backend/models/agent.go b/backend/models/agent.go index d2e3009..72c964a 100644 --- a/backend/models/agent.go +++ b/backend/models/agent.go @@ -9,9 +9,11 @@ type Agent struct { Hostname string `json:"hostname"` IP string `json:"ip"` OPNsenseVersion string `json:"opnsense_version"` + AgentVersion string `json:"agent_version,omitempty"` RegisteredAt time.Time `json:"registered_at"` LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` CustomerID *int `json:"customer_id,omitempty"` + UpdateRequested bool `json:"update_requested"` } // AgentResponse erweitert Agent um den berechneten Status @@ -52,6 +54,7 @@ type RegisterRequest struct { Hostname string `json:"hostname"` IP string `json:"ip"` OPNsenseVersion string `json:"opnsense_version"` + AgentVersion string `json:"agent_version,omitempty"` } // RegisterResponse diff --git a/backend/models/system.go b/backend/models/system.go index d750f33..154c910 100644 --- a/backend/models/system.go +++ b/backend/models/system.go @@ -172,11 +172,15 @@ type CronJob struct { // HeartbeatRequest type HeartbeatRequest struct { - AgentID string `json:"agent_id"` - SystemData SystemData `json:"system_data"` + AgentID string `json:"agent_id"` + AgentVersion string `json:"agent_version,omitempty"` + SystemData SystemData `json:"system_data"` } // HeartbeatResponse type HeartbeatResponse struct { - Message string `json:"message"` + Message string `json:"message"` + UpdateAvailable bool `json:"update_available,omitempty"` + UpdateVersion string `json:"update_version,omitempty"` + UpdateHash string `json:"update_hash,omitempty"` } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a121275..af1d446 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,8 @@ import Agents from './pages/Agents' import AgentDetail from './pages/AgentDetail' import Customers from './pages/Customers' import SettingsPage from './pages/SettingsPage' +import Tunnels from './pages/Tunnels' +import Firmware from './pages/Firmware' function ProtectedRoute({ children }) { const { isAuthenticated, loading } = useAuthStore() @@ -49,7 +51,9 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9e573bc..247dcfe 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -52,7 +52,9 @@ class ApiClient { } post(path, body) { - return this.request(path, { method: 'POST', body: JSON.stringify(body) }) + const opts = { method: 'POST' } + if (body !== undefined) opts.body = JSON.stringify(body) + return this.request(path, opts) } put(path, body) { @@ -138,6 +140,22 @@ class ApiClient { return this.get(`/api/v1/agents/${agentId}/backups`) } + async downloadBackup(agentId, backupId) { + const headers = {} + if (this.token) headers['Authorization'] = `Bearer ${this.token}` + const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}/backups/${backupId}?format=xml`, { headers }) + if (!res.ok) throw new Error('Download fehlgeschlagen') + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `config_${agentId.substring(0, 8)}_${backupId}.xml` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + triggerBackup(agentId) { return this.post(`/api/v1/agents/${agentId}/backup`) } @@ -194,6 +212,47 @@ class ApiClient { deleteUser(id) { return this.del(`/api/v1/users/${id}`) } + + changePassword(userId, password) { + return this.put(`/api/v1/users/${userId}/password`, { password }) + } + + // Firmware + getFirmwareInfo() { + return this.get('/api/v1/firmware') + } + + async uploadFirmware(version, platform, file) { + const headers = {} + if (this.token) headers['Authorization'] = `Bearer ${this.token}` + const res = await fetch(`${API_BASE}/api/v1/firmware/upload?version=${encodeURIComponent(version)}&platform=${encodeURIComponent(platform)}`, { + method: 'POST', + headers, + body: file, + }) + if (res.status === 401) { + this.setToken(null) + window.location.href = '/login' + throw new Error('Unauthorized') + } + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || 'Upload fehlgeschlagen') + } + return res.json() + } + + requestAgentUpdate(agentId) { + return this.post(`/api/v1/agents/${agentId}/request-update`) + } + + cancelAgentUpdate(agentId) { + return this.del(`/api/v1/agents/${agentId}/request-update`) + } + + requestUpdateAll() { + return this.post('/api/v1/agents/request-update-all') + } } export const api = new ApiClient() diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index 584ca12..86a1173 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -3,18 +3,21 @@ import api from '../api/client' import StatusBadge from './StatusBadge' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, - Globe, Key, Download, Terminal, Wifi, + Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, } from 'lucide-react' const tabs = [ { id: 'overview', label: 'Uebersicht' }, { id: 'interfaces', label: 'Interfaces' }, { id: 'services', label: 'Dienste' }, + { id: 'tunnels', label: 'Tunnel' }, { id: 'vpn', label: 'VPN' }, + { id: 'wireguard', label: 'WireGuard' }, { id: 'routes', label: 'Routen' }, { id: 'dhcp', label: 'DHCP' }, { id: 'certs', label: 'Zertifikate' }, { id: 'backups', label: 'Backups' }, + { id: 'agent', label: 'Agent' }, ] export default function AgentPanel({ agentId, customers, onClose, onReload }) { @@ -52,9 +55,7 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {

{agent.name}

- {cust && ( -
{cust.number} — {cust.name}
- )} +
{agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'}
@@ -92,8 +93,12 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { ) : tab === 'services' ? ( + ) : tab === 'tunnels' ? ( + ) : tab === 'vpn' ? ( + ) : tab === 'wireguard' ? ( + ) : tab === 'routes' ? ( ) : tab === 'dhcp' ? ( @@ -102,17 +107,77 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { ) : tab === 'backups' ? ( + ) : tab === 'agent' ? ( + ) : null} ) } +function CustomerAssign({ agent, customers, current, onReload }) { + const [editing, setEditing] = useState(false) + const [saving, setSaving] = useState(false) + + const handleChange = async (e) => { + const val = e.target.value + setSaving(true) + try { + if (val === '') { + await api.unassignCustomer(agent.id) + } else { + await api.assignCustomer(agent.id, parseInt(val)) + } + if (onReload) onReload() + } catch (err) { + console.error(err) + } finally { + setSaving(false) + setEditing(false) + } + } + + if (editing) { + return ( +
+ + {saving && Speichere...} +
+ ) + } + + return ( +
setEditing(true)} + title="Kunde aendern" + > + {current ? ( + {current.number} — {current.name} + ) : ( + Kein Kunde zugewiesen (klicken zum Zuweisen) + )} +
+ ) +} + function Panel({ onClose, children }) { return ( <>
-
+
{children}