Compare commits
10 Commits
54ddc0b902
...
b9d7383b21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d7383b21 | ||
|
|
d312a33d9e | ||
|
|
3afd5c8445 | ||
|
|
9d5f060cbd | ||
|
|
3916dc8bee | ||
|
|
be7cd32d45 | ||
|
|
65c0e6145b | ||
|
|
9035da89ce | ||
|
|
ebd3aecdb5 | ||
|
|
5fc46a8148 |
18
.env.example
Normal file
18
.env.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# docker-compose Konfiguration
|
||||||
|
# Kopiere diese Datei nach .env und trage deine Werte ein
|
||||||
|
|
||||||
|
# IP oder Hostname des Servers auf dem du docker compose ausfuehrst
|
||||||
|
# (wird vom Browser genutzt um das Backend zu erreichen)
|
||||||
|
BACKEND_HOST=192.168.1.100
|
||||||
|
BACKEND_PORT=8443
|
||||||
|
|
||||||
|
# Datenbank-Passwort (frei waehlbar)
|
||||||
|
DB_PASSWORD=sicheres_db_passwort
|
||||||
|
|
||||||
|
# JWT Secret — langer zufaelliger String (min. 32 Zeichen)
|
||||||
|
# Beispiel: openssl rand -hex 32
|
||||||
|
JWT_SECRET=ZUFAELLIGER_STRING_MIN_32_ZEICHEN
|
||||||
|
|
||||||
|
# API Key — frei waehlbar, wird fuer Agent und Frontend benoetigt
|
||||||
|
# Beispiel: openssl rand -hex 16
|
||||||
|
API_KEY=SELBST_GEWAEHLTER_API_KEY
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -34,10 +34,16 @@ updater/rmm-updater
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
# .env hat echte IPs und API-Keys — nie einchecken
|
# config.js hat echte IPs und API-Keys — nie einchecken
|
||||||
frontend/.env
|
frontend/src/config.js
|
||||||
!frontend/src/config.example.js
|
!frontend/src/config.example.js
|
||||||
|
|
||||||
|
# .env Dateien haben echte Credentials — nie einchecken
|
||||||
|
frontend/.env
|
||||||
|
!frontend/.env.example
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# ========================
|
# ========================
|
||||||
# OS / Editor
|
# OS / Editor
|
||||||
# ========================
|
# ========================
|
||||||
|
|||||||
8
Makefile
8
Makefile
@ -1,11 +1,10 @@
|
|||||||
.PHONY: all backend agent agent-linux agent-windows updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend
|
.PHONY: all backend agent agent-linux updater-freebsd updater-linux plugin clean certs release deploy-backend deploy-frontend
|
||||||
|
|
||||||
VERSION ?= 0.0.0
|
VERSION ?= 0.0.0
|
||||||
|
|
||||||
BACKEND_BIN = build/rmm-backend
|
BACKEND_BIN = build/rmm-backend
|
||||||
AGENT_BIN = build/rmm-agent
|
AGENT_BIN = build/rmm-agent
|
||||||
AGENT_LINUX_BIN = build/rmm-agent-linux
|
AGENT_LINUX_BIN = build/rmm-agent-linux
|
||||||
AGENT_WINDOWS_BIN = build/rmm-agent-windows.exe
|
|
||||||
UPDATER_FREEBSD_BIN = build/rmm-updater-freebsd
|
UPDATER_FREEBSD_BIN = build/rmm-updater-freebsd
|
||||||
UPDATER_LINUX_BIN = build/rmm-updater-linux
|
UPDATER_LINUX_BIN = build/rmm-updater-linux
|
||||||
|
|
||||||
@ -29,11 +28,6 @@ agent-linux:
|
|||||||
cd agent-linux && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_LINUX_BIN) .
|
cd agent-linux && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_LINUX_BIN) .
|
||||||
@echo "==> $(AGENT_LINUX_BIN) erstellt"
|
@echo "==> $(AGENT_LINUX_BIN) erstellt"
|
||||||
|
|
||||||
agent-windows:
|
|
||||||
@echo "==> Building Agent Windows (windows/amd64)..."
|
|
||||||
cd agent-windows && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_WINDOWS_BIN) .
|
|
||||||
@echo "==> $(AGENT_WINDOWS_BIN) erstellt"
|
|
||||||
|
|
||||||
updater-freebsd:
|
updater-freebsd:
|
||||||
@echo "==> Building Updater (freebsd/amd64)..."
|
@echo "==> Building Updater (freebsd/amd64)..."
|
||||||
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_FREEBSD_BIN) .
|
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_FREEBSD_BIN) .
|
||||||
|
|||||||
182
README.md
182
README.md
@ -158,13 +158,179 @@ Client ──► Backend:ProxyPort ──► WebSocket (Binary) ──► Agent
|
|||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
|
### Docker (empfohlen)
|
||||||
|
|
||||||
|
Voraussetzung: Docker installiert (`curl -fsSL https://get.docker.com | sh`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make all # Backend + Agent + Updater bauen
|
git clone <repo-url> && cd rmm
|
||||||
make certs # TLS-Zertifikate generieren
|
git checkout release
|
||||||
make deploy-backend # Backend deployen
|
cp .env.example .env # .env anpassen (IP-Adresse, Passwörter, API-Key)
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent auf OPNsense installieren → siehe [agent-bsd/README.md](agent-bsd/README.md)
|
Fertig. Frontend unter `http://<BACKEND_HOST>`.
|
||||||
|
|
||||||
|
Vollständige Anleitung mit allen Details, Troubleshooting und Backup: **[docs/DOCKER.md](docs/DOCKER.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Manuell (ohne Docker)
|
||||||
|
|
||||||
|
|
||||||
|
### 1. Voraussetzungen
|
||||||
|
|
||||||
|
**Backend-Server** (Linux, Debian 12+ empfohlen):
|
||||||
|
```bash
|
||||||
|
# PostgreSQL 17 + TimescaleDB
|
||||||
|
apt install -y postgresql postgresql-client
|
||||||
|
# TimescaleDB: https://docs.timescale.com/self-hosted/latest/install/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build-System** (macOS oder Linux):
|
||||||
|
```bash
|
||||||
|
# Go 1.24+
|
||||||
|
go version # muss >= 1.24 sein
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Konfiguration
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cp backend/config.yaml.example backend/config.yaml
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
# backend/config.yaml (wichtigste Felder)
|
||||||
|
listen_addr: ":8443"
|
||||||
|
db_dsn: "postgres://rmm:PASSWORT@localhost:5432/rmm?sslmode=disable"
|
||||||
|
jwt_secret: "ZUFAELLIGER_STRING_MIN_32_ZEICHEN"
|
||||||
|
api_key: "SELBST_GEWAEHLTER_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Hinweis:** Den `api_key` legst du selbst fest — z.B. eine UUID oder ein langer zufaelliger String.
|
||||||
|
> Denselben Wert traegst du gleich in die Frontend-`.env` ein. Du brauchst kein laufendes
|
||||||
|
> System dafuer. Ueber die Einstellungen-Seite koennen spaeter weitere Keys (z.B. pro Agent)
|
||||||
|
> hinzugefuegt werden.
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
```
|
||||||
|
```env
|
||||||
|
# frontend/.env
|
||||||
|
VITE_BACKEND_HOST=192.168.1.100 # IP/Hostname deines Backend-Servers
|
||||||
|
VITE_BACKEND_PORT=8443
|
||||||
|
VITE_API_KEY=SELBST_GEWAEHLTER_API_KEY # identisch mit api_key aus backend/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Datenbank anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Als postgres-User auf dem Backend-Server:
|
||||||
|
sudo -u postgres psql << 'SQL'
|
||||||
|
CREATE USER rmm WITH PASSWORD 'PASSWORT';
|
||||||
|
CREATE DATABASE rmm OWNER rmm;
|
||||||
|
\c rmm
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tabellen werden beim ersten Backend-Start automatisch angelegt (Auto-Migration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alles in einem Schritt:
|
||||||
|
make all
|
||||||
|
|
||||||
|
# Oder einzeln:
|
||||||
|
make backend # → build/rmm-backend (linux/amd64)
|
||||||
|
make agent-bsd # → build/rmm-agent-bsd (freebsd/amd64)
|
||||||
|
make agent-linux # → build/rmm-agent-linux (linux/amd64)
|
||||||
|
make frontend # → frontend/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Backend deployen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Binary + Config auf den Server kopieren:
|
||||||
|
scp build/rmm-backend root@<backend-server>:/opt/rmm/
|
||||||
|
scp backend/config.yaml root@<backend-server>:/opt/rmm/
|
||||||
|
|
||||||
|
# Systemd-Service einrichten:
|
||||||
|
cat > /etc/systemd/system/rmm-backend.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=RMM Backend
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/rmm/rmm-backend
|
||||||
|
WorkingDirectory=/opt/rmm
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now rmm-backend
|
||||||
|
systemctl status rmm-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Der erste Start legt automatisch einen Admin-User an (Credentials werden ins Log geschrieben).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Frontend deployen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build (mit .env-Konfiguration aus Schritt 2):
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
|
# Auf Web-Server kopieren (z.B. Nginx):
|
||||||
|
scp -r frontend/dist/* root@<frontend-server>:/var/www/html/
|
||||||
|
|
||||||
|
# Nginx-Konfiguration (einfach):
|
||||||
|
cat > /etc/nginx/sites-available/rmm << 'EOF'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /var/www/html;
|
||||||
|
index index.html;
|
||||||
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
ln -s /etc/nginx/sites-available/rmm /etc/nginx/sites-enabled/
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Erste Anmeldung & 2FA
|
||||||
|
|
||||||
|
1. Frontend unter `http://<frontend-server>` aufrufen
|
||||||
|
2. Mit dem beim ersten Backend-Start erstellten Admin-Account anmelden
|
||||||
|
3. Einstellungen → **Zwei-Faktor-Authentifizierung** → Einrichten (empfohlen)
|
||||||
|
4. TOTP-Secret in Aegis / Google Authenticator eingeben, ersten Code bestätigen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
|---------|--------|
|
||||||
|
| Backend startet nicht | `journalctl -u rmm-backend -f` — häufig DB-Verbindung oder Port belegt |
|
||||||
|
| Agent verbindet nicht | API-Key in `agent/config.yaml` prüfen, TLS-Zertifikat ggf. als trusted markieren |
|
||||||
|
| Frontend zeigt keine Daten | `VITE_BACKEND_HOST` und `VITE_API_KEY` in `.env` prüfen, neu bauen |
|
||||||
|
| TimescaleDB fehlt | `CREATE EXTENSION timescaledb` in der rmm-Datenbank ausführen |
|
||||||
|
|
||||||
## Dateistruktur
|
## Dateistruktur
|
||||||
|
|
||||||
@ -210,12 +376,12 @@ rmm/
|
|||||||
- **Persistente Agent-ID**: Ueberlebt Agent- und Backend-Neustarts
|
- **Persistente Agent-ID**: Ueberlebt Agent- und Backend-Neustarts
|
||||||
- **FreeBSD-kompatibel**: Volle Pfade (/usr/bin/netstat etc.), daemon statt nohup
|
- **FreeBSD-kompatibel**: Volle Pfade (/usr/bin/netstat etc.), daemon statt nohup
|
||||||
|
|
||||||
## Zugangsdaten
|
## Zugangsdaten (nach Installation)
|
||||||
|
|
||||||
```
|
```
|
||||||
Backend: https://your-backend:8443
|
Backend: https://<backend-ip>:8443
|
||||||
API-Key: YOUR_API_KEY
|
API-Key: in backend/config.yaml → api_key
|
||||||
Frontend: http://your-backend (admin / Start!123)
|
Frontend: https://<frontend-ip> (Login: admin / Passwort beim ersten Start gesetzt)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|||||||
@ -47,8 +47,6 @@ func (h *Handler) HandleCommand(msg Message) {
|
|||||||
response = h.handleUpdate(msg)
|
response = h.handleUpdate(msg)
|
||||||
case "zpoolscrub":
|
case "zpoolscrub":
|
||||||
response = h.handleZpoolScrub(msg)
|
response = h.handleZpoolScrub(msg)
|
||||||
case "proxmox_action":
|
|
||||||
response = h.handleProxmoxAction(msg)
|
|
||||||
case "pty_start":
|
case "pty_start":
|
||||||
response = h.handlePTYStart(msg)
|
response = h.handlePTYStart(msg)
|
||||||
case "pty_stop":
|
case "pty_stop":
|
||||||
@ -483,65 +481,3 @@ func (h *Handler) handleZpoolScrub(msg Message) Message {
|
|||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleProxmoxAction: VM/CT starten oder stoppen via pvesh
|
|
||||||
func (h *Handler) handleProxmoxAction(msg Message) Message {
|
|
||||||
response := Message{Type: "response", ID: msg.ID}
|
|
||||||
|
|
||||||
vmType, _ := msg.Params["type"].(string) // "vm" oder "ct"
|
|
||||||
action, _ := msg.Params["action"].(string) // "start", "stop", "shutdown"
|
|
||||||
vmidFloat, ok := msg.Params["vmid"].(float64)
|
|
||||||
if !ok {
|
|
||||||
response.Status = "error"
|
|
||||||
response.Error = "Parameter 'vmid' erforderlich"
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
vmid := int(vmidFloat)
|
|
||||||
|
|
||||||
if vmType != "vm" && vmType != "ct" {
|
|
||||||
response.Status = "error"
|
|
||||||
response.Error = "Parameter 'type' muss 'vm' oder 'ct' sein"
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
if action != "start" && action != "stop" && action != "shutdown" {
|
|
||||||
response.Status = "error"
|
|
||||||
response.Error = "Parameter 'action' muss 'start', 'stop' oder 'shutdown' sein"
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node-Name ermitteln
|
|
||||||
nodeName, _ := os.Hostname()
|
|
||||||
if nodeName == "" {
|
|
||||||
nodeName = "localhost"
|
|
||||||
}
|
|
||||||
|
|
||||||
// pvesh-Pfad aufbauen
|
|
||||||
var apiPath string
|
|
||||||
if vmType == "vm" {
|
|
||||||
apiPath = fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", nodeName, vmid, action)
|
|
||||||
} else {
|
|
||||||
if action == "shutdown" {
|
|
||||||
action = "stop" // LXC kennt kein "shutdown", stop reicht
|
|
||||||
}
|
|
||||||
apiPath = fmt.Sprintf("/nodes/%s/lxc/%d/status/%s", nodeName, vmid, action)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Proxmox Action: pvesh create %s", apiPath)
|
|
||||||
|
|
||||||
cmd := exec.Command("/usr/bin/pvesh", "create", apiPath, "--output-format", "json")
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Proxmox Action Fehler: %v — %s", err, string(output))
|
|
||||||
response.Status = "error"
|
|
||||||
response.Error = fmt.Sprintf("pvesh fehlgeschlagen: %v — %s", err, strings.TrimSpace(string(output)))
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Proxmox Action erfolgreich: %s %s %d", action, vmType, vmid)
|
|
||||||
response.Status = "ok"
|
|
||||||
response.Data = map[string]interface{}{
|
|
||||||
"message": fmt.Sprintf("%s %d: %s ausgeführt", vmType, vmid, action),
|
|
||||||
"output": strings.TrimSpace(string(output)),
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
baseURL string
|
|
||||||
apiKey string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(baseURL, apiKey string, insecure bool) *Client {
|
|
||||||
return &Client{
|
|
||||||
baseURL: baseURL,
|
|
||||||
apiKey: apiKey,
|
|
||||||
httpClient: &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisterRequest struct {
|
|
||||||
AgentID string `json:"agent_id,omitempty"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
IP string `json:"ip"`
|
|
||||||
AgentVersion string `json:"agent_version,omitempty"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisterResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Register(req RegisterRequest) (string, error) {
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
resp, err := c.doRequest("POST", "/api/v1/agent/register", body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("Registrierung fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(data))
|
|
||||||
}
|
|
||||||
var r RegisterResponse
|
|
||||||
if err := json.Unmarshal(data, &r); err != nil {
|
|
||||||
return "", fmt.Errorf("JSON Parse: %v", err)
|
|
||||||
}
|
|
||||||
return r.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeartbeatRequest struct {
|
|
||||||
AgentID string `json:"agent_id"`
|
|
||||||
AgentVersion string `json:"agent_version,omitempty"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
SystemData json.RawMessage `json:"system_data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeartbeatResponse struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
UpdateAvailable bool `json:"update_available"`
|
|
||||||
UpdateVersion string `json:"update_version,omitempty"`
|
|
||||||
UpdateHash string `json:"update_hash,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Heartbeat(req HeartbeatRequest) (*HeartbeatResponse, error) {
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
resp, err := c.doRequest("POST", "/api/v1/agent/heartbeat", body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("Heartbeat fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(data))
|
|
||||||
}
|
|
||||||
var r HeartbeatResponse
|
|
||||||
json.Unmarshal(data, &r)
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) doRequest(method, path string, body []byte) (*http.Response, error) {
|
|
||||||
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("X-API-Key", c.apiKey)
|
|
||||||
return c.httpClient.Do(req)
|
|
||||||
}
|
|
||||||
@ -1,448 +0,0 @@
|
|||||||
package collector
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
"golang.org/x/sys/windows/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemData ist das Haupt-Daten-Struct das an das Backend gesendet wird
|
|
||||||
type SystemData struct {
|
|
||||||
Windows *WindowsData `json:"windows,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WindowsData struct {
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
OSVersion string `json:"os_version"`
|
|
||||||
OSBuild string `json:"os_build"`
|
|
||||||
UptimeSecs int64 `json:"uptime_seconds"`
|
|
||||||
CPU CPUInfo `json:"cpu"`
|
|
||||||
Memory MemoryInfo `json:"memory"`
|
|
||||||
Disks []DiskInfo `json:"disks"`
|
|
||||||
Services []SvcInfo `json:"services,omitempty"`
|
|
||||||
InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"`
|
|
||||||
WAU *WAUInfo `json:"wau,omitempty"`
|
|
||||||
Domain string `json:"domain,omitempty"`
|
|
||||||
LastUpdate time.Time `json:"last_update"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CPUInfo struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Cores int `json:"cores"`
|
|
||||||
LogicalCores int `json:"logical_cores"`
|
|
||||||
LoadPercent float64 `json:"load_percent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MemoryInfo struct {
|
|
||||||
TotalBytes uint64 `json:"total_bytes"`
|
|
||||||
AvailableBytes uint64 `json:"available_bytes"`
|
|
||||||
UsedPercent float64 `json:"used_percent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiskInfo struct {
|
|
||||||
Drive string `json:"drive"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
TotalBytes uint64 `json:"total_bytes"`
|
|
||||||
FreeBytes uint64 `json:"free_bytes"`
|
|
||||||
UsedPercent float64 `json:"used_percent"`
|
|
||||||
Filesystem string `json:"filesystem,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SvcInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
StartType string `json:"start_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SoftwareInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
Publisher string `json:"publisher,omitempty"`
|
|
||||||
InstallDate string `json:"install_date,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MEMORYSTATUSEX fuer GlobalMemoryStatusEx
|
|
||||||
type memoryStatusEx struct {
|
|
||||||
dwLength uint32
|
|
||||||
dwMemoryLoad uint32
|
|
||||||
ullTotalPhys uint64
|
|
||||||
ullAvailPhys uint64
|
|
||||||
ullTotalPageFile uint64
|
|
||||||
ullAvailPageFile uint64
|
|
||||||
ullTotalVirtual uint64
|
|
||||||
ullAvailVirtual uint64
|
|
||||||
ullAvailExtendedVirtual uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
|
||||||
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
|
||||||
procGetTickCount64 = kernel32.NewProc("GetTickCount64")
|
|
||||||
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Collect sammelt alle Systemdaten
|
|
||||||
func Collect() (*SystemData, error) {
|
|
||||||
wd := &WindowsData{
|
|
||||||
LastUpdate: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hostname
|
|
||||||
if h, err := os.Hostname(); err == nil {
|
|
||||||
wd.Hostname = h
|
|
||||||
}
|
|
||||||
|
|
||||||
// OS-Version
|
|
||||||
wd.OSVersion, wd.OSBuild = getOSVersion()
|
|
||||||
|
|
||||||
// Uptime
|
|
||||||
wd.UptimeSecs = getUptime()
|
|
||||||
|
|
||||||
// CPU
|
|
||||||
wd.CPU = getCPUInfo()
|
|
||||||
|
|
||||||
// Memory
|
|
||||||
wd.Memory = getMemoryInfo()
|
|
||||||
|
|
||||||
// Disks
|
|
||||||
wd.Disks = getDiskInfo()
|
|
||||||
|
|
||||||
// Domain
|
|
||||||
wd.Domain = getDomain()
|
|
||||||
|
|
||||||
// Installierte Software (Registry)
|
|
||||||
wd.InstalledSoftware = getInstalledSoftware()
|
|
||||||
|
|
||||||
// WAU-Status (Installation + Konfiguration + Pending Updates)
|
|
||||||
wauInfo := getWAUInfo(wd.InstalledSoftware)
|
|
||||||
wd.WAU = &wauInfo
|
|
||||||
|
|
||||||
return &SystemData{Windows: wd}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOSVersion() (version, build string) {
|
|
||||||
// Registry: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion
|
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
|
||||||
`SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE)
|
|
||||||
if err != nil {
|
|
||||||
return "Windows (unbekannt)", ""
|
|
||||||
}
|
|
||||||
defer k.Close()
|
|
||||||
|
|
||||||
productName, _, _ := k.GetStringValue("ProductName")
|
|
||||||
displayVersion, _, _ := k.GetStringValue("DisplayVersion")
|
|
||||||
currentBuild, _, _ := k.GetStringValue("CurrentBuild")
|
|
||||||
ubr, _, _ := k.GetIntegerValue("UBR")
|
|
||||||
|
|
||||||
// Windows 11 erkennen: Build >= 22000, aber ProductName sagt noch "Windows 10"
|
|
||||||
buildNum := 0
|
|
||||||
fmt.Sscanf(currentBuild, "%d", &buildNum)
|
|
||||||
if buildNum >= 22000 && strings.Contains(productName, "Windows 10") {
|
|
||||||
productName = strings.Replace(productName, "Windows 10", "Windows 11", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if displayVersion != "" {
|
|
||||||
version = fmt.Sprintf("%s %s", productName, displayVersion)
|
|
||||||
} else {
|
|
||||||
version = productName
|
|
||||||
}
|
|
||||||
if ubr > 0 {
|
|
||||||
build = fmt.Sprintf("%s.%d", currentBuild, ubr)
|
|
||||||
} else {
|
|
||||||
build = currentBuild
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUptime() int64 {
|
|
||||||
ms, _, _ := procGetTickCount64.Call()
|
|
||||||
return int64(ms) / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCPUInfo() CPUInfo {
|
|
||||||
info := CPUInfo{}
|
|
||||||
|
|
||||||
// Modell aus Registry
|
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
|
||||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
|
||||||
if err == nil {
|
|
||||||
defer k.Close()
|
|
||||||
info.Model, _, _ = k.GetStringValue("ProcessorNameString")
|
|
||||||
info.Model = strings.TrimSpace(info.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kern-Anzahl
|
|
||||||
info.LogicalCores = getLogicalCoreCount()
|
|
||||||
info.Cores = getPhysicalCoreCount()
|
|
||||||
if info.Cores == 0 {
|
|
||||||
info.Cores = info.LogicalCores
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU-Last via PowerShell (einmalig, nicht ideal aber zuverlässig)
|
|
||||||
info.LoadPercent = getCPULoad()
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
type systemInfo struct {
|
|
||||||
wProcessorArchitecture uint16
|
|
||||||
wReserved uint16
|
|
||||||
dwPageSize uint32
|
|
||||||
lpMinimumApplicationAddress uintptr
|
|
||||||
lpMaximumApplicationAddress uintptr
|
|
||||||
dwActiveProcessorMask uintptr
|
|
||||||
dwNumberOfProcessors uint32
|
|
||||||
dwProcessorType uint32
|
|
||||||
dwAllocationGranularity uint32
|
|
||||||
wProcessorLevel uint16
|
|
||||||
wProcessorRevision uint16
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLogicalCoreCount() int {
|
|
||||||
var si systemInfo
|
|
||||||
kernel32.NewProc("GetSystemInfo").Call(uintptr(unsafe.Pointer(&si)))
|
|
||||||
return int(si.dwNumberOfProcessors)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPhysicalCoreCount() int {
|
|
||||||
out, err := runPS("(Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfCores -Sum).Sum")
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
n, _ := strconv.Atoi(strings.TrimSpace(out))
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCPULoad() float64 {
|
|
||||||
// GetSystemTimes: idle, kernel (includes idle), user
|
|
||||||
type filetime struct{ lo, hi uint32 }
|
|
||||||
var idle, kernel, user filetime
|
|
||||||
|
|
||||||
r, _, _ := procGetSystemTimes.Call(
|
|
||||||
uintptr(unsafe.Pointer(&idle)),
|
|
||||||
uintptr(unsafe.Pointer(&kernel)),
|
|
||||||
uintptr(unsafe.Pointer(&user)),
|
|
||||||
)
|
|
||||||
if r == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
toUint64 := func(f filetime) uint64 { return uint64(f.hi)<<32 | uint64(f.lo) }
|
|
||||||
|
|
||||||
t1idle := toUint64(idle)
|
|
||||||
t1kernel := toUint64(kernel)
|
|
||||||
t1user := toUint64(user)
|
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
r, _, _ = procGetSystemTimes.Call(
|
|
||||||
uintptr(unsafe.Pointer(&idle)),
|
|
||||||
uintptr(unsafe.Pointer(&kernel)),
|
|
||||||
uintptr(unsafe.Pointer(&user)),
|
|
||||||
)
|
|
||||||
if r == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
t2idle := toUint64(idle)
|
|
||||||
t2kernel := toUint64(kernel)
|
|
||||||
t2user := toUint64(user)
|
|
||||||
|
|
||||||
idleDelta := t2idle - t1idle
|
|
||||||
kernelDelta := t2kernel - t1kernel
|
|
||||||
userDelta := t2user - t1user
|
|
||||||
|
|
||||||
total := kernelDelta + userDelta
|
|
||||||
if total == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
busy := total - idleDelta
|
|
||||||
return float64(busy) / float64(total) * 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMemoryInfo() MemoryInfo {
|
|
||||||
var ms memoryStatusEx
|
|
||||||
ms.dwLength = uint32(unsafe.Sizeof(ms))
|
|
||||||
procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
|
||||||
|
|
||||||
used := ms.ullTotalPhys - ms.ullAvailPhys
|
|
||||||
usedPct := 0.0
|
|
||||||
if ms.ullTotalPhys > 0 {
|
|
||||||
usedPct = float64(used) / float64(ms.ullTotalPhys) * 100.0
|
|
||||||
}
|
|
||||||
return MemoryInfo{
|
|
||||||
TotalBytes: ms.ullTotalPhys,
|
|
||||||
AvailableBytes: ms.ullAvailPhys,
|
|
||||||
UsedPercent: usedPct,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDiskInfo() []DiskInfo {
|
|
||||||
var disks []DiskInfo
|
|
||||||
|
|
||||||
// Alle logischen Laufwerke ermitteln
|
|
||||||
buf := make([]uint16, 256)
|
|
||||||
kernel32.NewProc("GetLogicalDriveStringsW").Call(
|
|
||||||
uintptr(len(buf)),
|
|
||||||
uintptr(unsafe.Pointer(&buf[0])),
|
|
||||||
)
|
|
||||||
|
|
||||||
// buf enthält null-separierte Strings ("C:\\\0D:\\\0\0")
|
|
||||||
drives := windows.UTF16ToString(buf)
|
|
||||||
for _, drive := range strings.Split(drives, "\x00") {
|
|
||||||
drive = strings.TrimSpace(drive)
|
|
||||||
if len(drive) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nur lokale Festplatten (DRIVE_FIXED = 3)
|
|
||||||
driveType := kernel32.NewProc("GetDriveTypeW")
|
|
||||||
drivePath := windows.StringToUTF16Ptr(drive)
|
|
||||||
t, _, _ := driveType.Call(uintptr(unsafe.Pointer(drivePath)))
|
|
||||||
if t != 3 { // DRIVE_FIXED
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speicherplatz
|
|
||||||
var freeBytesToCaller, totalBytes, freeBytes uint64
|
|
||||||
kernel32.NewProc("GetDiskFreeSpaceExW").Call(
|
|
||||||
uintptr(unsafe.Pointer(drivePath)),
|
|
||||||
uintptr(unsafe.Pointer(&freeBytesToCaller)),
|
|
||||||
uintptr(unsafe.Pointer(&totalBytes)),
|
|
||||||
uintptr(unsafe.Pointer(&freeBytes)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if totalBytes == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
usedPct := float64(totalBytes-freeBytes) / float64(totalBytes) * 100.0
|
|
||||||
|
|
||||||
// Laufwerksbuchstabe ohne Backslash
|
|
||||||
letter := strings.TrimSuffix(strings.TrimSuffix(drive, `\`), "/")
|
|
||||||
|
|
||||||
disks = append(disks, DiskInfo{
|
|
||||||
Drive: letter,
|
|
||||||
TotalBytes: totalBytes,
|
|
||||||
FreeBytes: freeBytes,
|
|
||||||
UsedPercent: usedPct,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return disks
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDomain() string {
|
|
||||||
out, err := runPS("(Get-WmiObject Win32_ComputerSystem).Domain")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
d := strings.TrimSpace(out)
|
|
||||||
if d == "WORKGROUP" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToJSON serialisiert SystemData für den Heartbeat
|
|
||||||
func (s *SystemData) ToJSON() (json.RawMessage, error) {
|
|
||||||
b, err := json.Marshal(s)
|
|
||||||
return json.RawMessage(b), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPS führt einen PowerShell-Befehl aus und gibt stdout zurück
|
|
||||||
func runPS(cmd string) (string, error) {
|
|
||||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd).Output()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("PowerShell-Fehler (%s): %v", cmd[:min(len(cmd), 40)], err)
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInstalledSoftware liest installierte Software aus der Windows-Registry.
|
|
||||||
// Funktioniert auch unter SYSTEM-Account (kein winget noetig).
|
|
||||||
func getInstalledSoftware() []SoftwareInfo {
|
|
||||||
var result []SoftwareInfo
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
regPaths := []struct {
|
|
||||||
root registry.Key
|
|
||||||
path string
|
|
||||||
}{
|
|
||||||
{registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|
||||||
{registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rp := range regPaths {
|
|
||||||
k, err := registry.OpenKey(rp.root, rp.path, registry.READ|registry.ENUMERATE_SUB_KEYS)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subkeys, err := k.ReadSubKeyNames(-1)
|
|
||||||
k.Close()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, subkey := range subkeys {
|
|
||||||
sk, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name, _, _ := sk.GetStringValue("DisplayName")
|
|
||||||
sk.Close()
|
|
||||||
|
|
||||||
if name == "" || seen[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[name] = true
|
|
||||||
|
|
||||||
// Nochmal öffnen für weitere Felder (getrennt um Close sauber zu halten)
|
|
||||||
sk2, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
|
|
||||||
if err != nil {
|
|
||||||
result = append(result, SoftwareInfo{Name: name})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
version, _, _ := sk2.GetStringValue("DisplayVersion")
|
|
||||||
publisher, _, _ := sk2.GetStringValue("Publisher")
|
|
||||||
installDate, _, _ := sk2.GetStringValue("InstallDate")
|
|
||||||
|
|
||||||
// SystemComponent überspringen (Windows-interne Komponenten)
|
|
||||||
sysComp, _, _ := sk2.GetIntegerValue("SystemComponent")
|
|
||||||
sk2.Close()
|
|
||||||
if sysComp == 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, SoftwareInfo{
|
|
||||||
Name: name,
|
|
||||||
Version: version,
|
|
||||||
Publisher: publisher,
|
|
||||||
InstallDate: installDate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(result, func(i, j int) bool {
|
|
||||||
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package collector
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WAUInfo enthält den Status von Winget-AutoUpdate auf diesem Gerät
|
|
||||||
type WAUInfo struct {
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
IncludeURL string `json:"include_url,omitempty"`
|
|
||||||
ExcludeURL string `json:"exclude_url,omitempty"`
|
|
||||||
PendingCount int `json:"pending_updates"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// getWAUInfo liest WAU-Installations- und Konfigurationsstatus
|
|
||||||
func getWAUInfo(software []SoftwareInfo) WAUInfo {
|
|
||||||
info := WAUInfo{}
|
|
||||||
|
|
||||||
// WAU in installierter Software suchen
|
|
||||||
for _, s := range software {
|
|
||||||
lower := strings.ToLower(s.Name)
|
|
||||||
if strings.Contains(lower, "winget-autoupdate") || lower == "wau" {
|
|
||||||
info.Installed = true
|
|
||||||
info.Version = s.Version
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAU-Konfiguration aus Registry lesen
|
|
||||||
// WAU speichert seine Einstellungen unter HKLM\SOFTWARE\Winget-AutoUpdate
|
|
||||||
regPaths := []string{
|
|
||||||
`SOFTWARE\Winget-AutoUpdate`,
|
|
||||||
`SOFTWARE\Policies\Microsoft\Windows\Winget-AutoUpdate`,
|
|
||||||
}
|
|
||||||
for _, path := range regPaths {
|
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if url, _, err := k.GetStringValue("WAU_IncludeListURL"); err == nil && url != "" {
|
|
||||||
info.IncludeURL = url
|
|
||||||
}
|
|
||||||
if url, _, err := k.GetStringValue("WAU_ExcludeListURL"); err == nil && url != "" {
|
|
||||||
info.ExcludeURL = url
|
|
||||||
}
|
|
||||||
k.Close()
|
|
||||||
if info.IncludeURL != "" || info.ExcludeURL != "" {
|
|
||||||
info.Installed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending updates via winget upgrade --list zählen
|
|
||||||
wp := findWingetExe()
|
|
||||||
if wp != "" {
|
|
||||||
out, err := runPS(`& "` + wp + `" upgrade --list --accept-source-agreements 2>&1`)
|
|
||||||
if err == nil {
|
|
||||||
info.PendingCount = countWingetUpgradeLines(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// findWingetExe sucht winget.exe im System
|
|
||||||
func findWingetExe() string {
|
|
||||||
if path, err := exec.LookPath("winget"); err == nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
script := `Get-ChildItem "C:\Program Files\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`
|
|
||||||
out, err := runPS(script)
|
|
||||||
if err == nil && strings.TrimSpace(out) != "" {
|
|
||||||
return strings.TrimSpace(out)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// countWingetUpgradeLines zählt Pakete mit Updates aus "winget upgrade --list" Output
|
|
||||||
func countWingetUpgradeLines(output string) int {
|
|
||||||
lines := strings.Split(output, "\n")
|
|
||||||
count := 0
|
|
||||||
pastSeparator := false
|
|
||||||
for _, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if strings.Contains(trimmed, "---") {
|
|
||||||
pastSeparator = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if pastSeparator && trimmed != "" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
backend_url: "https://192.168.85.13:8443"
|
|
||||||
api_key: "AGENT_KEY_HERE"
|
|
||||||
agent_name: "PC-Name"
|
|
||||||
interval_seconds: 60
|
|
||||||
insecure: true
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
BackendURL string `yaml:"backend_url"`
|
|
||||||
APIKey string `yaml:"api_key"`
|
|
||||||
AgentName string `yaml:"agent_name"`
|
|
||||||
IntervalSeconds int `yaml:"interval_seconds"`
|
|
||||||
Insecure bool `yaml:"insecure"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cfg := &Config{
|
|
||||||
IntervalSeconds: 60,
|
|
||||||
Insecure: true,
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module github.com/cynfo/rmm-agent-windows
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gorilla/websocket v1.5.1
|
|
||||||
golang.org/x/sys v0.18.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require golang.org/x/net v0.17.0 // indirect
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
|
||||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
#Requires -RunAsAdministrator
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
RMM Windows Agent Installer
|
|
||||||
.EXAMPLE
|
|
||||||
.\install.ps1 -BackendURL "https://192.168.85.13:8443" -APIKey "abc123" -AgentName "PC-Muster"
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$true)] [string]$BackendURL,
|
|
||||||
[Parameter(Mandatory=$true)] [string]$APIKey,
|
|
||||||
[Parameter(Mandatory=$false)] [string]$AgentName = $env:COMPUTERNAME,
|
|
||||||
[Parameter(Mandatory=$false)] [switch]$Uninstall
|
|
||||||
)
|
|
||||||
|
|
||||||
$InstallDir = "C:\Program Files\RMMAgent"
|
|
||||||
$BinaryName = "rmm-agent-windows.exe"
|
|
||||||
$ServiceName = "RMMAgent"
|
|
||||||
$ConfigFile = "$InstallDir\config.yaml"
|
|
||||||
|
|
||||||
if ($Uninstall) {
|
|
||||||
Write-Host "Deinstalliere $ServiceName..."
|
|
||||||
if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
|
|
||||||
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
|
|
||||||
& "$InstallDir\$BinaryName" -uninstall
|
|
||||||
}
|
|
||||||
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Write-Host "Deinstallation abgeschlossen."
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verzeichnis anlegen
|
|
||||||
Write-Host "Erstelle Installationsverzeichnis: $InstallDir"
|
|
||||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
|
||||||
|
|
||||||
# Binary kopieren (muss im gleichen Ordner wie das Install-Script liegen)
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
$SourceBinary = Join-Path $ScriptDir $BinaryName
|
|
||||||
|
|
||||||
if (-not (Test-Path $SourceBinary)) {
|
|
||||||
Write-Error "Binary nicht gefunden: $SourceBinary"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Kopiere Binary nach $InstallDir..."
|
|
||||||
Copy-Item -Path $SourceBinary -Destination "$InstallDir\$BinaryName" -Force
|
|
||||||
|
|
||||||
# Konfiguration erstellen
|
|
||||||
Write-Host "Erstelle Konfiguration..."
|
|
||||||
$Config = @"
|
|
||||||
backend_url: "$BackendURL"
|
|
||||||
api_key: "$APIKey"
|
|
||||||
agent_name: "$AgentName"
|
|
||||||
interval_seconds: 60
|
|
||||||
insecure: true
|
|
||||||
"@
|
|
||||||
$Config | Out-File -FilePath $ConfigFile -Encoding UTF8 -Force
|
|
||||||
|
|
||||||
# Dienst installieren
|
|
||||||
Write-Host "Installiere Windows-Dienst..."
|
|
||||||
if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Host "Dienst vorhanden — stoppe und entferne alten Dienst..."
|
|
||||||
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
|
|
||||||
& "$InstallDir\$BinaryName" -uninstall
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
}
|
|
||||||
|
|
||||||
& "$InstallDir\$BinaryName" -config $ConfigFile -install
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Dienst-Installation fehlgeschlagen"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Dienst starten
|
|
||||||
Write-Host "Starte Dienst..."
|
|
||||||
Start-Service -Name $ServiceName
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
|
|
||||||
$svc = Get-Service -Name $ServiceName
|
|
||||||
Write-Host "Dienst-Status: $($svc.Status)"
|
|
||||||
|
|
||||||
if ($svc.Status -eq "Running") {
|
|
||||||
Write-Host "`nInstallation erfolgreich!" -ForegroundColor Green
|
|
||||||
Write-Host "Log: $InstallDir\rmm-agent.log"
|
|
||||||
} else {
|
|
||||||
Write-Warning "Dienst laeuft nicht — bitte Log pruefen: $InstallDir\rmm-agent.log"
|
|
||||||
}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows/svc"
|
|
||||||
"golang.org/x/sys/windows/svc/eventlog"
|
|
||||||
"golang.org/x/sys/windows/svc/mgr"
|
|
||||||
|
|
||||||
"github.com/cynfo/rmm-agent-windows/client"
|
|
||||||
"github.com/cynfo/rmm-agent-windows/collector"
|
|
||||||
"github.com/cynfo/rmm-agent-windows/config"
|
|
||||||
"github.com/cynfo/rmm-agent-windows/ws"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Version = "1.2.0"
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServiceName = "RMMAgent"
|
|
||||||
ServiceDisplayName = "RMM Agent"
|
|
||||||
ServiceDescription = "RMM Agent - Remote Monitoring & Management"
|
|
||||||
AgentIDFile = "agent_id.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// agentService implementiert das Windows Service Interface
|
|
||||||
type agentService struct {
|
|
||||||
cfg *config.Config
|
|
||||||
cfgPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
|
||||||
changes <- svc.Status{State: svc.StartPending}
|
|
||||||
stop := make(chan struct{})
|
|
||||||
go runAgent(s.cfg, s.cfgPath, stop)
|
|
||||||
changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
|
||||||
|
|
||||||
for c := range r {
|
|
||||||
switch c.Cmd {
|
|
||||||
case svc.Stop, svc.Shutdown:
|
|
||||||
changes <- svc.Status{State: svc.StopPending}
|
|
||||||
close(stop)
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var (
|
|
||||||
cfgPath = flag.String("config", defaultConfigPath(), "Pfad zur Konfigurationsdatei")
|
|
||||||
flagDebug = flag.Bool("debug", false, "Im Vordergrund ausfuehren (kein Dienst)")
|
|
||||||
flagInst = flag.Bool("install", false, "Als Windows-Dienst installieren")
|
|
||||||
flagUninst = flag.Bool("uninstall", false, "Windows-Dienst deinstallieren")
|
|
||||||
)
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// Logging in Datei (neben Binary)
|
|
||||||
logPath := filepath.Join(filepath.Dir(*cfgPath), "rmm-agent.log")
|
|
||||||
if lf, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
|
||||||
log.SetOutput(lf)
|
|
||||||
}
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
||||||
log.Printf("RMM Windows Agent v%s startet", Version)
|
|
||||||
|
|
||||||
cfg, err := config.Load(*cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Konfiguration laden fehlgeschlagen (%s): %v", *cfgPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *flagInst {
|
|
||||||
installService(cfg, *cfgPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if *flagUninst {
|
|
||||||
uninstallService()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interaktiv (debug) oder als Dienst?
|
|
||||||
isInteractive, err := svc.IsAnInteractiveSession()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("IsAnInteractiveSession: %v", err)
|
|
||||||
isInteractive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if *flagDebug || isInteractive {
|
|
||||||
log.Println("Starte im Debug-Modus (Vordergrund)")
|
|
||||||
stop := make(chan struct{})
|
|
||||||
runAgent(cfg, *cfgPath, stop)
|
|
||||||
} else {
|
|
||||||
if err := svc.Run(ServiceName, &agentService{cfg: cfg, cfgPath: *cfgPath}); err != nil {
|
|
||||||
log.Fatalf("Dienst-Fehler: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runAgent ist die Hauptschleife des Agents
|
|
||||||
func runAgent(cfg *config.Config, cfgPath string, stop <-chan struct{}) {
|
|
||||||
c := client.New(cfg.BackendURL, cfg.APIKey, cfg.Insecure)
|
|
||||||
|
|
||||||
// Agent-ID laden oder registrieren
|
|
||||||
agentID, err := loadOrRegister(c, cfg, cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Registrierung fehlgeschlagen: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("Agent-ID: %s", agentID)
|
|
||||||
|
|
||||||
// WS-Handler und -Client starten
|
|
||||||
handler := ws.NewHandler()
|
|
||||||
wsClient := ws.NewClient(cfg.BackendURL, agentID, cfg.APIKey, cfg.Insecure, handler)
|
|
||||||
go wsClient.Run(stop)
|
|
||||||
|
|
||||||
// Heartbeat-Schleife
|
|
||||||
interval := time.Duration(cfg.IntervalSeconds) * time.Second
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
// Ersten Heartbeat sofort
|
|
||||||
sendHeartbeat(c, cfg, agentID)
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
log.Println("Agent wird gestoppt")
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
sendHeartbeat(c, cfg, agentID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendHeartbeat(c *client.Client, cfg *config.Config, agentID string) {
|
|
||||||
sysData, err := collector.Collect()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Collector-Fehler: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rawData json.RawMessage
|
|
||||||
if sysData != nil {
|
|
||||||
rawData, _ = sysData.ToJSON()
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.Heartbeat(client.HeartbeatRequest{
|
|
||||||
AgentID: agentID,
|
|
||||||
AgentVersion: Version,
|
|
||||||
Platform: "windows",
|
|
||||||
SystemData: rawData,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Heartbeat fehlgeschlagen: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("Heartbeat OK")
|
|
||||||
|
|
||||||
if resp.UpdateAvailable && resp.UpdateVersion != "" && resp.UpdateVersion != Version {
|
|
||||||
log.Printf("Update verfuegbar: v%s → v%s", Version, resp.UpdateVersion)
|
|
||||||
go doSelfUpdate(cfg, resp.UpdateVersion, resp.UpdateHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// doSelfUpdate laedt das neue Binary herunter und startet einen
|
|
||||||
// PowerShell-Script der nach Dienst-Stop das Binary tauscht und neu startet.
|
|
||||||
func doSelfUpdate(cfg *config.Config, newVersion, expectedHash string) {
|
|
||||||
log.Printf("OTA-Update: Starte Download v%s", newVersion)
|
|
||||||
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("OTA-Update: Executable-Pfad: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dir := filepath.Dir(exe)
|
|
||||||
tmpBin := filepath.Join(dir, "rmm-agent-windows-update.exe")
|
|
||||||
scriptPath := filepath.Join(dir, "rmm-update.ps1")
|
|
||||||
|
|
||||||
// Binary herunterladen
|
|
||||||
dlURL := fmt.Sprintf("%s/api/v1/firmware/download?platform=windows&api_key=%s",
|
|
||||||
cfg.BackendURL, cfg.APIKey)
|
|
||||||
|
|
||||||
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure}}
|
|
||||||
hc := &http.Client{Transport: tr, Timeout: 10 * time.Minute}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", dlURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("OTA-Update: Request: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("X-API-Key", cfg.APIKey)
|
|
||||||
|
|
||||||
resp, err := hc.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("OTA-Update: Download: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
log.Printf("OTA-Update: HTTP %d", resp.StatusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Create(tmpBin)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("OTA-Update: Temp-Datei: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h := sha256.New()
|
|
||||||
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmpBin)
|
|
||||||
log.Printf("OTA-Update: Schreiben: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
// Hash pruefen
|
|
||||||
gotHash := hex.EncodeToString(h.Sum(nil))
|
|
||||||
if expectedHash != "" && gotHash != expectedHash {
|
|
||||||
os.Remove(tmpBin)
|
|
||||||
log.Printf("OTA-Update: Hash-Mismatch: erwartet=%s, erhalten=%s", expectedHash, gotHash)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("OTA-Update: Download OK (hash=%s), starte Update-Script", gotHash[:12])
|
|
||||||
|
|
||||||
// PowerShell-Script schreiben das nach Dienst-Stop das Binary tauscht
|
|
||||||
psScript := fmt.Sprintf(`
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
Stop-Service -Name RMMAgent -Force -ErrorAction SilentlyContinue
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
Copy-Item -Path "%s" -Destination "%s" -Force
|
|
||||||
Remove-Item -Path "%s" -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -Path "%s" -Force -ErrorAction SilentlyContinue
|
|
||||||
Start-Service -Name RMMAgent
|
|
||||||
`, tmpBin, exe, tmpBin, scriptPath)
|
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(psScript), 0644); err != nil {
|
|
||||||
log.Printf("OTA-Update: Script schreiben: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Script detached starten (laeuft weiter wenn dieser Prozess beendet wird)
|
|
||||||
cmd := exec.Command("powershell", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
||||||
"-WindowStyle", "Hidden", "-File", scriptPath)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Printf("OTA-Update: Script starten: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// cmd.Wait() nicht aufrufen — Script laeuft unabhaengig weiter
|
|
||||||
log.Printf("OTA-Update: Script gestartet (PID %d), Dienst wird gestoppt", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOrRegister liest die Agent-ID aus Datei oder registriert sich neu
|
|
||||||
func loadOrRegister(c *client.Client, cfg *config.Config, cfgPath string) (string, error) {
|
|
||||||
idFile := filepath.Join(filepath.Dir(cfgPath), AgentIDFile)
|
|
||||||
|
|
||||||
if data, err := os.ReadFile(idFile); err == nil {
|
|
||||||
id := string(data)
|
|
||||||
if len(id) > 0 {
|
|
||||||
// Re-registrieren um sicherzustellen dass der Agent im Backend bekannt ist
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
c.Register(client.RegisterRequest{
|
|
||||||
AgentID: id,
|
|
||||||
Name: cfg.AgentName,
|
|
||||||
Hostname: hostname,
|
|
||||||
IP: "",
|
|
||||||
AgentVersion: Version,
|
|
||||||
Platform: "windows",
|
|
||||||
})
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neu registrieren
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
name := cfg.AgentName
|
|
||||||
if name == "" {
|
|
||||||
name = hostname
|
|
||||||
}
|
|
||||||
id, err := c.Register(client.RegisterRequest{
|
|
||||||
Name: name,
|
|
||||||
Hostname: hostname,
|
|
||||||
AgentVersion: Version,
|
|
||||||
Platform: "windows",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("Registrierung: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(idFile, []byte(id), 0644); err != nil {
|
|
||||||
log.Printf("Agent-ID speichern fehlgeschlagen: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("Neu registriert als: %s", id)
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultConfigPath gibt den Standardpfad zur Konfigurationsdatei zurück
|
|
||||||
func defaultConfigPath() string {
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return `C:\Program Files\RMMAgent\config.yaml`
|
|
||||||
}
|
|
||||||
return filepath.Join(filepath.Dir(exe), "config.yaml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// installService installiert den Agent als Windows-Dienst
|
|
||||||
func installService(cfg *config.Config, cfgPath string) {
|
|
||||||
m, err := mgr.Connect()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Service Manager: %v", err)
|
|
||||||
}
|
|
||||||
defer m.Disconnect()
|
|
||||||
|
|
||||||
s, err := m.OpenService(ServiceName)
|
|
||||||
if err == nil {
|
|
||||||
s.Close()
|
|
||||||
log.Fatalf("Dienst '%s' existiert bereits — zuerst deinstallieren", ServiceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Executable-Pfad: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err = m.CreateService(ServiceName, exe, mgr.Config{
|
|
||||||
DisplayName: ServiceDisplayName,
|
|
||||||
Description: ServiceDescription,
|
|
||||||
StartType: mgr.StartAutomatic,
|
|
||||||
ErrorControl: mgr.ErrorNormal,
|
|
||||||
}, "-config", cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Dienst erstellen: %v", err)
|
|
||||||
}
|
|
||||||
defer s.Close()
|
|
||||||
|
|
||||||
// Event Log registrieren
|
|
||||||
eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info)
|
|
||||||
|
|
||||||
log.Printf("Dienst '%s' installiert", ServiceName)
|
|
||||||
log.Println("Starten mit: net start RMMAgent")
|
|
||||||
}
|
|
||||||
|
|
||||||
// uninstallService entfernt den Windows-Dienst
|
|
||||||
func uninstallService() {
|
|
||||||
m, err := mgr.Connect()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Service Manager: %v", err)
|
|
||||||
}
|
|
||||||
defer m.Disconnect()
|
|
||||||
|
|
||||||
s, err := m.OpenService(ServiceName)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Dienst nicht gefunden: %v", err)
|
|
||||||
}
|
|
||||||
defer s.Close()
|
|
||||||
|
|
||||||
s.Control(svc.Stop)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
if err := s.Delete(); err != nil {
|
|
||||||
log.Fatalf("Dienst loeschen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eventlog.Remove(ServiceName)
|
|
||||||
log.Printf("Dienst '%s' deinstalliert", ServiceName)
|
|
||||||
}
|
|
||||||
Binary file not shown.
@ -1,150 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
backendURL string
|
|
||||||
agentID string
|
|
||||||
apiKey string
|
|
||||||
insecure bool
|
|
||||||
conn *websocket.Conn
|
|
||||||
handler *Handler
|
|
||||||
reconnectWait time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(backendURL, agentID, apiKey string, insecure bool, handler *Handler) *Client {
|
|
||||||
return &Client{
|
|
||||||
backendURL: backendURL,
|
|
||||||
agentID: agentID,
|
|
||||||
apiKey: apiKey,
|
|
||||||
insecure: insecure,
|
|
||||||
handler: handler,
|
|
||||||
reconnectWait: 5 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Run(stop <-chan struct{}) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.connect(stop); err != nil {
|
|
||||||
log.Printf("WS-Verbindung fehlgeschlagen: %v — erneuter Versuch in %v", err, c.reconnectWait)
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-time.After(c.reconnectWait):
|
|
||||||
}
|
|
||||||
if c.reconnectWait < 60*time.Second {
|
|
||||||
c.reconnectWait *= 2
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c.reconnectWait = 5 * time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) connect(stop <-chan struct{}) error {
|
|
||||||
u, err := url.Parse(c.backendURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch u.Scheme {
|
|
||||||
case "https":
|
|
||||||
u.Scheme = "wss"
|
|
||||||
case "http":
|
|
||||||
u.Scheme = "ws"
|
|
||||||
}
|
|
||||||
u.Path = "/api/v1/agent/ws"
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("agent_id", c.agentID)
|
|
||||||
q.Set("api_key", c.apiKey)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
dialer := websocket.DefaultDialer
|
|
||||||
if c.insecure {
|
|
||||||
dialer = &websocket.Dialer{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
HandshakeTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, _, err := dialer.Dial(u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Dial fehlgeschlagen: %w", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
c.conn = conn
|
|
||||||
log.Printf("WS verbunden mit %s", c.backendURL)
|
|
||||||
|
|
||||||
conn.SetReadDeadline(time.Now().Add(70 * time.Second))
|
|
||||||
conn.SetPongHandler(func(string) error {
|
|
||||||
conn.SetReadDeadline(time.Now().Add(70 * time.Second))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
// Ping-Loop: alle 30s einen Ping senden um Verbindung am Leben zu halten
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for {
|
|
||||||
_, msg, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
|
||||||
log.Printf("WS Read-Fehler: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp := c.handler.Handle(msg); resp != nil {
|
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
conn.WriteMessage(websocket.TextMessage, resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
conn.WriteMessage(websocket.CloseMessage,
|
|
||||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
||||||
return nil
|
|
||||||
case <-done:
|
|
||||||
return fmt.Errorf("Verbindung getrennt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SendMessage(data []byte) error {
|
|
||||||
if c.conn == nil {
|
|
||||||
return fmt.Errorf("nicht verbunden")
|
|
||||||
}
|
|
||||||
return c.conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
|
||||||
@ -1,327 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
Command string `json:"command,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Params map[string]interface{} `json:"params,omitempty"` // Backend -> Agent
|
|
||||||
Data interface{} `json:"data,omitempty"` // Agent -> Backend (Response)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
wingetPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler() *Handler {
|
|
||||||
return &Handler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// winget gibt den Pfad zu winget.exe zurueck (cached)
|
|
||||||
func (h *Handler) winget() string {
|
|
||||||
if h.wingetPath != "" {
|
|
||||||
return h.wingetPath
|
|
||||||
}
|
|
||||||
// Direkt im PATH?
|
|
||||||
if path, err := exec.LookPath("winget"); err == nil {
|
|
||||||
h.wingetPath = path
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
// WindowsApps durchsuchen
|
|
||||||
script := `Get-ChildItem "C:\Program Files\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err == nil {
|
|
||||||
path := strings.TrimSpace(string(out))
|
|
||||||
if path != "" {
|
|
||||||
h.wingetPath = path
|
|
||||||
log.Printf("winget gefunden: %s", path)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("winget nicht gefunden")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Handle(raw []byte) []byte {
|
|
||||||
var msg Message
|
|
||||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
||||||
log.Printf("WS Parse-Fehler: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if msg.Type != "command" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp Message
|
|
||||||
switch msg.Command {
|
|
||||||
case "exec":
|
|
||||||
resp = h.handleExec(msg)
|
|
||||||
case "winget_list":
|
|
||||||
resp = h.handleWingetList(msg)
|
|
||||||
case "winget_install":
|
|
||||||
resp = h.handleWingetInstall(msg)
|
|
||||||
case "winget_upgrade":
|
|
||||||
resp = h.handleWingetUpgrade(msg)
|
|
||||||
case "winget_upgrade_all":
|
|
||||||
resp = h.handleWingetUpgradeAll(msg)
|
|
||||||
case "windows_updates_check":
|
|
||||||
resp = h.handleWinUpdatesCheck(msg)
|
|
||||||
case "windows_updates_install":
|
|
||||||
resp = h.handleWinUpdatesInstall(msg)
|
|
||||||
case "reboot":
|
|
||||||
resp = h.handleReboot(msg)
|
|
||||||
default:
|
|
||||||
resp = Message{Type: "response", ID: msg.ID, Status: "error",
|
|
||||||
Error: fmt.Sprintf("Unbekanntes Command: %s", msg.Command)}
|
|
||||||
}
|
|
||||||
|
|
||||||
out, _ := json.Marshal(resp)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleExec führt einen beliebigen Befehl aus (PowerShell oder cmd)
|
|
||||||
func (h *Handler) handleExec(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
|
|
||||||
data := msg.Params
|
|
||||||
if data == nil {
|
|
||||||
data, _ = msg.Data.(map[string]interface{})
|
|
||||||
}
|
|
||||||
command, _ := data["command"].(string)
|
|
||||||
if command == "" {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = "Kein Befehl angegeben"
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// winget durch vollen Pfad ersetzen falls noetig
|
|
||||||
if strings.Contains(command, "winget") {
|
|
||||||
if wp := h.winget(); wp != "" {
|
|
||||||
command = strings.ReplaceAll(command, "winget ", fmt.Sprintf(`& "%s" `, wp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutSecs := 60
|
|
||||||
if t, ok := data["timeout"].(float64); ok && t > 0 {
|
|
||||||
timeoutSecs = int(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := runWithTimeout(command, timeoutSecs)
|
|
||||||
if err != nil {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
} else {
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWingetList listet installierte Software auf
|
|
||||||
func (h *Handler) handleWingetList(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
wp := h.winget()
|
|
||||||
if wp == "" {
|
|
||||||
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
|
|
||||||
}
|
|
||||||
out, err := runWithTimeout(fmt.Sprintf(`& "%s" list --accept-source-agreements 2>&1`, wp), 60)
|
|
||||||
if err != nil && out == "" {
|
|
||||||
resp.Status = "error"; resp.Error = err.Error(); return resp
|
|
||||||
}
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWingetInstall installiert ein Paket
|
|
||||||
func (h *Handler) handleWingetInstall(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
data := msg.Params
|
|
||||||
if data == nil { data, _ = msg.Data.(map[string]interface{}) }
|
|
||||||
pkg, _ := data["package"].(string)
|
|
||||||
if pkg == "" {
|
|
||||||
resp.Status = "error"; resp.Error = "Kein Paket angegeben"; return resp
|
|
||||||
}
|
|
||||||
wp := h.winget()
|
|
||||||
if wp == "" {
|
|
||||||
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
|
|
||||||
}
|
|
||||||
cmd := fmt.Sprintf(`& "%s" install --id %s --silent --accept-package-agreements --accept-source-agreements 2>&1`, wp, sanitize(pkg))
|
|
||||||
out, err := runWithTimeout(cmd, 300)
|
|
||||||
if err != nil {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
} else {
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWingetUpgrade aktualisiert ein Paket
|
|
||||||
func (h *Handler) handleWingetUpgrade(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
data := msg.Params
|
|
||||||
if data == nil { data, _ = msg.Data.(map[string]interface{}) }
|
|
||||||
pkg, _ := data["package"].(string)
|
|
||||||
if pkg == "" {
|
|
||||||
resp.Status = "error"; resp.Error = "Kein Paket angegeben"; return resp
|
|
||||||
}
|
|
||||||
wp := h.winget()
|
|
||||||
if wp == "" {
|
|
||||||
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
|
|
||||||
}
|
|
||||||
cmd := fmt.Sprintf(`& "%s" upgrade --id %s --silent --accept-package-agreements --accept-source-agreements 2>&1`, wp, sanitize(pkg))
|
|
||||||
out, err := runWithTimeout(cmd, 300)
|
|
||||||
if err != nil {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = err.Error()
|
|
||||||
} else {
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWingetUpgradeAll aktualisiert alle Pakete
|
|
||||||
func (h *Handler) handleWingetUpgradeAll(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
wp := h.winget()
|
|
||||||
if wp == "" {
|
|
||||||
resp.Status = "error"; resp.Error = "winget nicht gefunden"; return resp
|
|
||||||
}
|
|
||||||
out, err := runWithTimeout(fmt.Sprintf(`& "%s" upgrade --all --silent --accept-package-agreements --accept-source-agreements 2>&1`, wp), 600)
|
|
||||||
if err != nil {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = err.Error()
|
|
||||||
} else {
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWinUpdatesCheck prüft verfügbare Windows-Updates
|
|
||||||
func (h *Handler) handleWinUpdatesCheck(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
script := `
|
|
||||||
$updateSession = New-Object -ComObject Microsoft.Update.Session
|
|
||||||
$searcher = $updateSession.CreateUpdateSearcher()
|
|
||||||
$result = $searcher.Search("IsInstalled=0 and Type='Software'")
|
|
||||||
$updates = @()
|
|
||||||
foreach ($u in $result.Updates) {
|
|
||||||
$updates += [PSCustomObject]@{
|
|
||||||
Title = $u.Title
|
|
||||||
KB = ($u.KBArticleIDs | Select-Object -First 1)
|
|
||||||
Size = $u.MaxDownloadSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$updates | ConvertTo-Json -Compress`
|
|
||||||
out, err := runPSScript(script, 120)
|
|
||||||
if err != nil {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = err.Error()
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"raw": out}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWinUpdatesInstall installiert alle ausstehenden Updates
|
|
||||||
func (h *Handler) handleWinUpdatesInstall(msg Message) Message {
|
|
||||||
resp := Message{Type: "response", ID: msg.ID}
|
|
||||||
script := `
|
|
||||||
$updateSession = New-Object -ComObject Microsoft.Update.Session
|
|
||||||
$searcher = $updateSession.CreateUpdateSearcher()
|
|
||||||
$result = $searcher.Search("IsInstalled=0 and Type='Software'")
|
|
||||||
if ($result.Updates.Count -eq 0) { Write-Output "Keine Updates verfuegbar"; exit 0 }
|
|
||||||
$downloader = $updateSession.CreateUpdateDownloader()
|
|
||||||
$downloader.Updates = $result.Updates
|
|
||||||
$downloader.Download()
|
|
||||||
$installer = $updateSession.CreateUpdateInstaller()
|
|
||||||
$installer.Updates = $result.Updates
|
|
||||||
$installResult = $installer.Install()
|
|
||||||
Write-Output "Installiert: $($result.Updates.Count) Updates, ResultCode: $($installResult.ResultCode)"`
|
|
||||||
out, err := runPSScript(script, 1800) // 30 Min Timeout
|
|
||||||
if err != nil {
|
|
||||||
resp.Status = "error"
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.Data = map[string]interface{}{"output": out}
|
|
||||||
} else {
|
|
||||||
resp.Status = "ok"
|
|
||||||
resp.Data = map[string]interface{}{"output": out, "reboot_required": strings.Contains(out, "ResultCode: 2")}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) handleReboot(msg Message) Message {
|
|
||||||
go func() {
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
exec.Command("shutdown", "/r", "/t", "10", "/c", "RMM-gesteuert").Run()
|
|
||||||
}()
|
|
||||||
return Message{Type: "response", ID: msg.ID, Status: "ok",
|
|
||||||
Data: map[string]interface{}{"message": "Neustart in 10 Sekunden"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hilfsfunktionen
|
|
||||||
|
|
||||||
func runWithTimeout(command string, timeoutSecs int) (string, error) {
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
|
|
||||||
out, err := withTimeout(cmd, time.Duration(timeoutSecs)*time.Second)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPS(command string) (string, error) {
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
|
|
||||||
b, err := cmd.CombinedOutput()
|
|
||||||
return strings.TrimSpace(string(b)), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPSScript(script string, timeoutSecs int) (string, error) {
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
|
|
||||||
out, err := withTimeout(cmd, time.Duration(timeoutSecs)*time.Second)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func withTimeout(cmd *exec.Cmd, timeout time.Duration) (string, error) {
|
|
||||||
done := make(chan struct{})
|
|
||||||
var out []byte
|
|
||||||
var err error
|
|
||||||
go func() {
|
|
||||||
out, err = cmd.CombinedOutput()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return strings.TrimSpace(string(out)), err
|
|
||||||
case <-time.After(timeout):
|
|
||||||
cmd.Process.Kill()
|
|
||||||
return "", fmt.Errorf("Timeout nach %v", timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitize(s string) string {
|
|
||||||
// Einfache Bereinigung — nur alphanumerisch, Punkt, Bindestrich
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
||||||
(r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
31
backend/Dockerfile
Normal file
31
backend/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o rmm-backend .
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/rmm-backend .
|
||||||
|
COPY config.yaml.example config.yaml.example
|
||||||
|
|
||||||
|
RUN mkdir -p certs
|
||||||
|
|
||||||
|
EXPOSE 8443
|
||||||
|
|
||||||
|
# Umgebungsvariablen mit Standardwerten (koennen alle per ENV ueberschrieben werden)
|
||||||
|
ENV RMM_LISTEN_ADDR=":8443" \
|
||||||
|
RMM_DB_HOST="db" \
|
||||||
|
RMM_DB_PORT="5432" \
|
||||||
|
RMM_DB_USER="rmm" \
|
||||||
|
RMM_DB_NAME="rmm"
|
||||||
|
|
||||||
|
CMD ["./rmm-backend"]
|
||||||
@ -23,56 +23,29 @@ func (h *Handler) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|||||||
// POST /api/v1/apikeys
|
// POST /api/v1/apikeys
|
||||||
func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Permissions string `json:"permissions"` // "agent" | "read" | "write" | "admin"
|
|
||||||
CustomerID *int `json:"customer_id"` // optional, null = global
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||||
writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
|
writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions validieren, default: "read"
|
|
||||||
validPerms := map[string]bool{"agent": true, "read": true, "write": true, "admin": true}
|
|
||||||
if req.Permissions == "" {
|
|
||||||
req.Permissions = "read"
|
|
||||||
}
|
|
||||||
if !validPerms[req.Permissions] {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungueltige Permissions (agent|read|write|admin)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zufaelligen 32-Byte Key generieren
|
// Zufaelligen 32-Byte Key generieren
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
key := hex.EncodeToString(b)
|
key := hex.EncodeToString(b)
|
||||||
|
|
||||||
apiKey, err := h.db.CreateAPIKey(req.Name, key, req.Permissions, req.CustomerID)
|
apiKey, err := h.db.CreateAPIKey(req.Name, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
|
log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
|
||||||
writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Neuer API-Key erstellt: %s (%s...) perm=%s", apiKey.Name, key[:8], apiKey.Permissions)
|
log.Printf("Neuer API-Key erstellt: %s (%s...)", apiKey.Name, key[:8])
|
||||||
writeJSON(w, http.StatusCreated, apiKey)
|
writeJSON(w, http.StatusCreated, apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}/apikeys
|
|
||||||
func (h *Handler) listCustomerAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungueltige ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keys, err := h.db.ListCustomerAPIKeys(id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Laden fehlgeschlagen")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/v1/apikeys/{id}
|
// DELETE /api/v1/apikeys/{id}
|
||||||
func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|||||||
@ -1,36 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/cynfo/rmm-backend/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateCustomerKey erstellt automatisch einen API-Key fuer einen Kunden
|
|
||||||
func generateCustomerKey(database *db.Database, customerID int, number, name, perm string) (*db.APIKey, error) {
|
|
||||||
b := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
key := hex.EncodeToString(b)
|
|
||||||
var label string
|
|
||||||
switch perm {
|
|
||||||
case "agent":
|
|
||||||
label = fmt.Sprintf("Agent — %s %s", number, name)
|
|
||||||
case "read":
|
|
||||||
label = fmt.Sprintf("Read-only — %s %s", number, name)
|
|
||||||
default:
|
|
||||||
label = fmt.Sprintf("%s — %s %s", perm, number, name)
|
|
||||||
}
|
|
||||||
return database.CreateAPIKey(label, key, perm, &customerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/customers
|
// GET /api/v1/customers
|
||||||
func (h *Handler) listCustomers(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listCustomers(w http.ResponseWriter, r *http.Request) {
|
||||||
customers, err := h.db.GetCustomers()
|
customers, err := h.db.GetCustomers()
|
||||||
@ -56,20 +31,8 @@ func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatisch Agent-Key erstellen
|
|
||||||
agentKey, err := generateCustomerKey(h.db, c.ID, req.Number, req.Name, "agent")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Auto-Agent-Key fuer Kunde %s fehlgeschlagen: %v", req.Number, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.auditLog(r, "customer.create", "customer", req.Number, req.Name, "")
|
h.auditLog(r, "customer.create", "customer", req.Number, req.Name, "")
|
||||||
|
writeJSON(w, http.StatusCreated, c)
|
||||||
resp := map[string]interface{}{
|
|
||||||
"customer": c,
|
|
||||||
"agent_key": agentKey,
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}
|
// GET /api/v1/customers/{id}
|
||||||
|
|||||||
@ -184,20 +184,8 @@ func (h *Handler) pushAgentUpdate(hub *ws.Hub) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("id")
|
agentID := r.PathValue("id")
|
||||||
|
|
||||||
// Plattform aus Request-Body oder Query-Parameter lesen, default linux
|
// TODO: Agent-Plattform aus DB lesen; fuer jetzt default freebsd
|
||||||
platform := r.URL.Query().Get("platform")
|
version, hash, binary, err := h.db.GetLatestFirmware("freebsd")
|
||||||
if platform == "" {
|
|
||||||
var reqBody struct {
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
json.Unmarshal(body, &reqBody)
|
|
||||||
platform = reqBody.Platform
|
|
||||||
}
|
|
||||||
if platform == "" {
|
|
||||||
platform = "linux"
|
|
||||||
}
|
|
||||||
version, hash, binary, err := h.db.GetLatestFirmware(platform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Keine Firmware verfuegbar")
|
writeError(w, http.StatusNotFound, "Keine Firmware verfuegbar")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -59,10 +59,6 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("GET /api/v1/apikeys", h.listAPIKeys)
|
mux.HandleFunc("GET /api/v1/apikeys", h.listAPIKeys)
|
||||||
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
||||||
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys)
|
|
||||||
|
|
||||||
// WAU-Regeln
|
|
||||||
h.setupWAURoutes(mux)
|
|
||||||
|
|
||||||
// System Settings
|
// System Settings
|
||||||
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
|
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
|
||||||
@ -255,12 +251,7 @@ func (h *Handler) heartbeat(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.db.UpdateAgentVersion(req.AgentID, req.AgentVersion)
|
h.db.UpdateAgentVersion(req.AgentID, req.AgentVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genutzten API-Key speichern (aus Middleware-Kontext)
|
if err := h.db.SaveSystemData(req.AgentID, &req.SystemData); err != nil {
|
||||||
if keyInfo := GetAPIKeyFromContext(r); keyInfo != nil {
|
|
||||||
h.db.UpdateAgentAPIKey(req.AgentID, keyInfo.Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.db.SaveSystemDataRaw(req.AgentID, req.SystemData); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "nicht gefunden") {
|
if strings.Contains(err.Error(), "nicht gefunden") {
|
||||||
writeError(w, http.StatusNotFound, "Agent nicht gefunden")
|
writeError(w, http.StatusNotFound, "Agent nicht gefunden")
|
||||||
return
|
return
|
||||||
@ -342,15 +333,10 @@ func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// system_data als Roh-JSON ausgeben (plattformunabhaengig)
|
|
||||||
var rawSD interface{} = sysData
|
|
||||||
if sysData != nil && len(sysData.RawJSON) > 0 {
|
|
||||||
rawSD = json.RawMessage(sysData.RawJSON)
|
|
||||||
}
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"agent": agent,
|
"agent": agent,
|
||||||
"status": models.CalculateAgentStatus(agent.LastHeartbeat),
|
"status": models.CalculateAgentStatus(agent.LastHeartbeat),
|
||||||
"system_data": rawSD,
|
"system_data": sysData,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,9 +15,9 @@ func (h *Handler) uploadInstaller(w http.ResponseWriter, r *http.Request) {
|
|||||||
if platform == "" {
|
if platform == "" {
|
||||||
platform = "freebsd"
|
platform = "freebsd"
|
||||||
}
|
}
|
||||||
validPlatforms := map[string]bool{"freebsd": true, "linux": true, "windows": true}
|
validPlatforms := map[string]bool{"freebsd": true, "linux": true}
|
||||||
if !validPlatforms[platform] {
|
if !validPlatforms[platform] {
|
||||||
writeError(w, http.StatusBadRequest, "Ungueltige Plattform (freebsd, linux, windows)")
|
writeError(w, http.StatusBadRequest, "Ungueltige Plattform (freebsd, linux)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,21 +8,18 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
db "github.com/cynfo/rmm-backend/db"
|
"github.com/cynfo/rmm-backend/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const UserContextKey contextKey = "user"
|
const UserContextKey contextKey = "user"
|
||||||
|
|
||||||
// APIKeyContextKey fuer Key-Infos im Request-Context
|
|
||||||
const APIKeyContextKey contextKey = "apikey"
|
|
||||||
|
|
||||||
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
|
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
|
||||||
type APIKeyCache struct {
|
type APIKeyCache struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
keys map[string]*db.APIKey // key -> APIKey mit Permissions + CustomerID
|
keys map[string]bool
|
||||||
lastLoad time.Time
|
lastLoad time.Time
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
@ -30,7 +27,7 @@ type APIKeyCache struct {
|
|||||||
func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
||||||
c := &APIKeyCache{
|
c := &APIKeyCache{
|
||||||
db: database,
|
db: database,
|
||||||
keys: make(map[string]*db.APIKey),
|
keys: make(map[string]bool),
|
||||||
ttl: 30 * time.Second,
|
ttl: 30 * time.Second,
|
||||||
}
|
}
|
||||||
c.refresh()
|
c.refresh()
|
||||||
@ -38,15 +35,14 @@ func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *APIKeyCache) refresh() {
|
func (c *APIKeyCache) refresh() {
|
||||||
dbKeys, err := c.db.ListAPIKeys()
|
dbKeys, err := c.db.GetAllAPIKeys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
|
log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newKeys := make(map[string]*db.APIKey, len(dbKeys))
|
newKeys := make(map[string]bool, len(dbKeys))
|
||||||
for i := range dbKeys {
|
for _, k := range dbKeys {
|
||||||
k := dbKeys[i]
|
newKeys[k] = true
|
||||||
newKeys[k.Key] = &k
|
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.keys = newKeys
|
c.keys = newKeys
|
||||||
@ -54,62 +50,44 @@ func (c *APIKeyCache) refresh() {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *APIKeyCache) Get(key string) *db.APIKey {
|
func (c *APIKeyCache) IsValid(key string) bool {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
expired := time.Since(c.lastLoad) > c.ttl
|
expired := time.Since(c.lastLoad) > c.ttl
|
||||||
info := c.keys[key]
|
valid := c.keys[key]
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
if expired {
|
if expired {
|
||||||
c.refresh()
|
c.refresh()
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
info = c.keys[key]
|
valid = c.keys[key]
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if info != nil {
|
if valid {
|
||||||
go c.db.UpdateAPIKeyLastUsed(key)
|
go c.db.UpdateAPIKeyLastUsed(key)
|
||||||
}
|
}
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValid prueft nur ob Key existiert (Abwaertskompatibilitaet)
|
return valid
|
||||||
func (c *APIKeyCache) IsValid(key string) bool {
|
|
||||||
return c.Get(key) != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
|
// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
|
||||||
// Setzt APIKey-Info im Context fuer nachgelagerte Permission-Checks.
|
|
||||||
func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
|
func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Try API key first (Header oder Query-Parameter)
|
// Try API key first
|
||||||
key := r.Header.Get("X-API-Key")
|
key := r.Header.Get("X-API-Key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = r.URL.Query().Get("api_key")
|
key = r.URL.Query().Get("api_key")
|
||||||
}
|
}
|
||||||
if key != "" {
|
if key != "" && cache.IsValid(key) {
|
||||||
if info := cache.Get(key); info != nil {
|
next.ServeHTTP(w, r)
|
||||||
// Permission-Check
|
return
|
||||||
if !hasAPIKeyPermission(info, r) {
|
|
||||||
http.Error(w, `{"error":"forbidden: unzureichende Berechtigungen"}`, http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), APIKeyContextKey, info)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try JWT — Authorization Header oder ?token= Query-Parameter (fuer WebSocket)
|
// Try JWT
|
||||||
var jwtToken string
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
jwtToken = strings.TrimPrefix(authHeader, "Bearer ")
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
} else if t := r.URL.Query().Get("token"); t != "" {
|
claims, err := auth.ValidateToken(token)
|
||||||
jwtToken = t
|
|
||||||
}
|
|
||||||
if jwtToken != "" {
|
|
||||||
claims, err := auth.ValidateToken(jwtToken)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
@ -121,66 +99,6 @@ func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Hand
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasAPIKeyPermission prueft ob ein API-Key fuer den aktuellen Request berechtigt ist
|
|
||||||
func hasAPIKeyPermission(key *db.APIKey, r *http.Request) bool {
|
|
||||||
perm := key.Permissions
|
|
||||||
method := r.Method
|
|
||||||
path := r.URL.Path
|
|
||||||
|
|
||||||
switch perm {
|
|
||||||
case "agent":
|
|
||||||
// Agent-Endpunkte: Registrierung, WS-Connect, Heartbeat, OTA-Update
|
|
||||||
return path == "/api/v1/agent/ws" ||
|
|
||||||
path == "/api/v1/agent/heartbeat" ||
|
|
||||||
path == "/api/v1/agent/register" ||
|
|
||||||
path == "/api/v1/firmware" ||
|
|
||||||
path == "/api/v1/firmware/download" ||
|
|
||||||
path == "/api/v1/installer/download" ||
|
|
||||||
strings.HasPrefix(path, "/api/v1/agents/")
|
|
||||||
|
|
||||||
case "read":
|
|
||||||
// Nur GET-Requests, keine destruktiven Aktionen
|
|
||||||
if method != http.MethodGet {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Customer-Scope pruefen (falls gesetzt)
|
|
||||||
return checkCustomerScope(key, r)
|
|
||||||
|
|
||||||
case "write":
|
|
||||||
// Alles ausser Terminal (Terminal ist JWT-only)
|
|
||||||
// Customer-Scope pruefen
|
|
||||||
return checkCustomerScope(key, r)
|
|
||||||
|
|
||||||
case "admin":
|
|
||||||
// Vollen Zugriff (kein Terminal — das prueft JWTOnlyAuth separat)
|
|
||||||
return true
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkCustomerScope prueft ob ein customer-scoped Key nur auf seinen Kunden zugreift
|
|
||||||
func checkCustomerScope(key *db.APIKey, r *http.Request) bool {
|
|
||||||
if key.CustomerID == nil {
|
|
||||||
return true // Kein Scope = globaler Zugriff
|
|
||||||
}
|
|
||||||
// Customer-ID aus dem Pfad extrahieren (z.B. /api/v1/agents/{id} -> Agent-Customer pruefen)
|
|
||||||
// Fuer jetzt: customer-scoped Keys werden im Handler weiter eingeschraenkt (via GetAPIKeyFromContext)
|
|
||||||
// Der eigentliche Scope-Check passiert in den Handlern die GetAPIKeyFromContext nutzen
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAPIKeyFromContext gibt den API-Key aus dem Context zurueck (nil wenn JWT-Auth)
|
|
||||||
func GetAPIKeyFromContext(r *http.Request) *db.APIKey {
|
|
||||||
if v := r.Context().Value(APIKeyContextKey); v != nil {
|
|
||||||
if k, ok := v.(*db.APIKey); ok {
|
|
||||||
return k
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
|
// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
|
||||||
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
|
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
|
||||||
keySet := make(map[string]bool, len(validKeys))
|
keySet := make(map[string]bool, len(validKeys))
|
||||||
@ -213,38 +131,6 @@ func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTOnlyAuth - Nur JWT akzeptieren, kein API-Key (z.B. fuer Terminal-Zugriff)
|
|
||||||
// Erfordert echten Login mit User/Pass (+2FA), kein Maschinentoken reicht.
|
|
||||||
func JWTOnlyAuth(auth *AuthHandler, next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var jwtToken string
|
|
||||||
|
|
||||||
// Authorization Header
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
jwtToken = strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
}
|
|
||||||
// ?token= Query-Parameter (fuer WebSocket, Browser kann keinen Header setzen)
|
|
||||||
if jwtToken == "" {
|
|
||||||
jwtToken = r.URL.Query().Get("token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if jwtToken == "" {
|
|
||||||
http.Error(w, `{"error":"unauthorized: JWT erforderlich"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := auth.ValidateToken(jwtToken)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"unauthorized: ungültiger Token"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORS Middleware
|
// CORS Middleware
|
||||||
func CORS(next http.Handler) http.Handler {
|
func CORS(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -17,8 +17,8 @@ var termUpgrader = websocket.Upgrader{
|
|||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
}
|
}
|
||||||
|
|
||||||
// TerminalHandler bridget Browser-WebSocket mit Agent-PTY (JWT-only, kein API-Key)
|
// terminalHandler bridget Browser-WebSocket mit Agent-PTY
|
||||||
func (h *Handler) TerminalHandler(hub *ws.Hub) http.HandlerFunc {
|
func (h *Handler) terminalHandler(hub *ws.Hub) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("id")
|
agentID := r.PathValue("id")
|
||||||
if agentID == "" {
|
if agentID == "" {
|
||||||
|
|||||||
@ -37,7 +37,6 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub))
|
mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub))
|
||||||
|
|
||||||
// Backup-Endpoints
|
// Backup-Endpoints
|
||||||
mux.HandleFunc("POST /api/v1/agents/{id}/proxmox/action", h.proxmoxAction(hub))
|
|
||||||
mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub))
|
mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub))
|
||||||
mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups())
|
mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups())
|
||||||
mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup())
|
mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup())
|
||||||
@ -45,7 +44,7 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups())
|
mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups())
|
||||||
|
|
||||||
// Terminal-Endpoint (Browser -> Agent PTY)
|
// Terminal-Endpoint (Browser -> Agent PTY)
|
||||||
// Terminal wird NICHT hier registriert — wird in main.go unter JWTOnlyAuth eingebunden
|
mux.HandleFunc("GET /api/v1/agents/{id}/terminal", h.terminalHandler(hub))
|
||||||
|
|
||||||
// WebSocket-Endpoint
|
// WebSocket-Endpoint
|
||||||
mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub))
|
mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub))
|
||||||
@ -266,36 +265,6 @@ func (h *Handler) agentReboot(hub *ws.Hub) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// proxmoxAction: VM/CT starten oder stoppen
|
|
||||||
func (h *Handler) proxmoxAction(hub *ws.Hub) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
agentID := r.PathValue("id")
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Type string `json:"type"` // "vm" oder "ct"
|
|
||||||
VMID int `json:"vmid"`
|
|
||||||
Action string `json:"action"` // "start", "stop", "shutdown"
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.VMID <= 0 || req.Type == "" || req.Action == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "type, vmid und action erforderlich")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := map[string]interface{}{
|
|
||||||
"type": req.Type,
|
|
||||||
"vmid": float64(req.VMID),
|
|
||||||
"action": req.Action,
|
|
||||||
}
|
|
||||||
|
|
||||||
h.auditLog(r, "proxmox_action", "agent", agentID, "", fmt.Sprintf("%s %s %d", req.Action, req.Type, req.VMID))
|
|
||||||
h.sendCommandAndWait(hub, agentID, "proxmox_action", params, 60, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendAgentCommand erstellt einen einfachen Command-Handler ohne Request-Body
|
// sendAgentCommand erstellt einen einfachen Command-Handler ohne Request-Body
|
||||||
func (h *Handler) sendAgentCommand(hub *ws.Hub, command string, params map[string]interface{}, timeoutSec int) http.HandlerFunc {
|
func (h *Handler) sendAgentCommand(hub *ws.Hub, command string, params map[string]interface{}, timeoutSec int) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -1,203 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupWAURoutes registriert alle WAU-Endpunkte
|
|
||||||
func (h *Handler) setupWAURoutes(mux *http.ServeMux) {
|
|
||||||
// Authentifizierte Endpunkte (API-Key oder JWT)
|
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/rules", h.listWAURules)
|
|
||||||
mux.HandleFunc("POST /api/v1/customers/{id}/wau/rules", h.addWAURule)
|
|
||||||
mux.HandleFunc("DELETE /api/v1/customers/{id}/wau/rules/{rid}", h.deleteWAURule)
|
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
|
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/compliance", h.getWAUCompliance)
|
|
||||||
|
|
||||||
// Compliance-Summary für alle Kunden
|
|
||||||
mux.HandleFunc("GET /api/v1/wau/compliance/summary", h.getWAUComplianceSummary)
|
|
||||||
|
|
||||||
// Plaintext-Endpunkte für WAU (kein Auth nötig — enthält nur App-IDs)
|
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/included", h.wauIncludedList)
|
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/excluded", h.wauExcludedList)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}/wau/rules
|
|
||||||
func (h *Handler) listWAURules(w http.ResponseWriter, r *http.Request) {
|
|
||||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rules, err := h.db.ListWAURules(customerID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, rules)
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/v1/customers/{id}/wau/rules
|
|
||||||
func (h *Handler) addWAURule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
WingetID string `json:"winget_id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
RuleType string `json:"rule_type"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.WingetID = strings.TrimSpace(req.WingetID)
|
|
||||||
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
|
||||||
|
|
||||||
if req.WingetID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "winget_id erforderlich")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RuleType != "allow" && req.RuleType != "block" {
|
|
||||||
writeError(w, http.StatusBadRequest, "rule_type muss 'allow' oder 'block' sein")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DisplayName == "" {
|
|
||||||
req.DisplayName = req.WingetID
|
|
||||||
}
|
|
||||||
|
|
||||||
rule, err := h.db.AddWAURule(customerID, req.WingetID, req.DisplayName, req.RuleType)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.auditLog(r, "wau.rule.add", "customer", strconv.Itoa(customerID), "",
|
|
||||||
req.RuleType+": "+req.WingetID)
|
|
||||||
writeJSON(w, http.StatusCreated, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/v1/customers/{id}/wau/rules/{rid}
|
|
||||||
func (h *Handler) deleteWAURule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ruleID, err := strconv.Atoi(r.PathValue("rid"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Regel-ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := h.db.DeleteWAURule(customerID, ruleID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusNotFound, "Regel nicht gefunden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.auditLog(r, "wau.rule.delete", "customer", strconv.Itoa(customerID), "", strconv.Itoa(ruleID))
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Regel gelöscht"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}/wau/inventory
|
|
||||||
func (h *Handler) getWAUInventory(w http.ResponseWriter, r *http.Request) {
|
|
||||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, err := h.db.GetWAUSoftwareInventory(customerID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des Inventars")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}/wau/included → Plaintext für WAU
|
|
||||||
func (h *Handler) wauIncludedList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.serveWAUList(w, r, "allow")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}/wau/excluded → Plaintext für WAU
|
|
||||||
func (h *Handler) wauExcludedList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.serveWAUList(w, r, "block")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) serveWAUList(w http.ResponseWriter, r *http.Request, ruleType string) {
|
|
||||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Ungültige Kunden-ID", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ids, err := h.db.GetWAUList(customerID, ruleType)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Fehler", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(strings.Join(ids, "\r\n")))
|
|
||||||
if len(ids) > 0 {
|
|
||||||
w.Write([]byte("\r\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wauExpectedURLs leitet die erwarteten WAU-URLs aus dem Request-Host ab
|
|
||||||
func wauExpectedURLs(r *http.Request, customerID int) (includeURL, excludeURL string) {
|
|
||||||
scheme := "https"
|
|
||||||
if r.TLS == nil && (r.Header.Get("X-Forwarded-Proto") == "" || r.Header.Get("X-Forwarded-Proto") == "http") {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
|
||||||
scheme = fwd
|
|
||||||
}
|
|
||||||
host := r.Host
|
|
||||||
base := scheme + "://" + host
|
|
||||||
includeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/included"
|
|
||||||
excludeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/excluded"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/customers/{id}/wau/compliance
|
|
||||||
func (h *Handler) getWAUCompliance(w http.ResponseWriter, r *http.Request) {
|
|
||||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
includeURL, excludeURL := wauExpectedURLs(r, customerID)
|
|
||||||
agents, err := h.db.GetWAUCompliance(customerID, includeURL, excludeURL)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"agents": agents,
|
|
||||||
"include_url": includeURL,
|
|
||||||
"exclude_url": excludeURL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/wau/compliance/summary — Zusammenfassung für alle Kunden
|
|
||||||
func (h *Handler) getWAUComplianceSummary(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Ohne customer_id können wir keine URLs ableiten — wir geben Summary ohne URL-Check zurück
|
|
||||||
// oder nutzen customer_id=0 als Wildcard (kein URL-Check)
|
|
||||||
summaries, err := h.db.GetAllCustomersWAUSummary("", "")
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, summaries)
|
|
||||||
}
|
|
||||||
@ -1,8 +1,23 @@
|
|||||||
# RMM Backend Konfiguration
|
# RMM Backend Konfiguration
|
||||||
|
# Kopiere diese Datei nach config.yaml und passe die Werte an.
|
||||||
|
# Alle Werte koennen auch als Umgebungsvariablen gesetzt werden (Docker).
|
||||||
|
|
||||||
listen_addr: ":8443"
|
listen_addr: ":8443"
|
||||||
tls_cert: "certs/server.crt"
|
tls_cert: "certs/server.crt"
|
||||||
tls_key: "certs/server.key"
|
tls_key: "certs/server.key"
|
||||||
db_path: "rmm.db"
|
|
||||||
|
|
||||||
api_keys:
|
# Datenbank
|
||||||
- "YOUR_API_KEY"
|
database:
|
||||||
|
host: "127.0.0.1" # ENV: RMM_DB_HOST
|
||||||
|
port: 5432 # ENV: RMM_DB_PORT
|
||||||
|
user: "rmm" # ENV: RMM_DB_USER
|
||||||
|
password: "" # ENV: RMM_DB_PASSWORD
|
||||||
|
dbname: "rmm" # ENV: RMM_DB_NAME
|
||||||
|
sslmode: "disable"
|
||||||
|
|
||||||
|
# Sicherheit — zufaellige Strings waehlen, nie die Beispielwerte verwenden!
|
||||||
|
jwt_secret: "ZUFAELLIGER_STRING_MIN_32_ZEICHEN" # ENV: RMM_JWT_SECRET
|
||||||
|
|
||||||
|
# API-Keys fuer Agent und Frontend-Zugriff
|
||||||
|
api_keys: # ENV: RMM_API_KEY (wird zusaetzlich angehaengt)
|
||||||
|
- "SELBST_GEWAEHLTER_API_KEY"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/cynfo/rmm-backend/db"
|
"github.com/cynfo/rmm-backend/db"
|
||||||
@ -41,7 +42,7 @@ func Load(path string) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Env overrides
|
// Env overrides — alle Werte koennen via ENV ueberschrieben werden (z.B. Docker)
|
||||||
if v := os.Getenv("RMM_LISTEN_ADDR"); v != "" {
|
if v := os.Getenv("RMM_LISTEN_ADDR"); v != "" {
|
||||||
cfg.ListenAddr = v
|
cfg.ListenAddr = v
|
||||||
}
|
}
|
||||||
@ -51,12 +52,32 @@ func Load(path string) (*Config, error) {
|
|||||||
if v := os.Getenv("RMM_TLS_KEY"); v != "" {
|
if v := os.Getenv("RMM_TLS_KEY"); v != "" {
|
||||||
cfg.TLSKey = v
|
cfg.TLSKey = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("RMM_JWT_SECRET"); v != "" {
|
||||||
|
cfg.JWTSecret = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("RMM_API_KEY"); v != "" {
|
||||||
|
cfg.APIKeys = append(cfg.APIKeys, v)
|
||||||
|
}
|
||||||
if v := os.Getenv("RMM_DB_HOST"); v != "" {
|
if v := os.Getenv("RMM_DB_HOST"); v != "" {
|
||||||
cfg.Database.Host = v
|
cfg.Database.Host = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("RMM_DB_PORT"); v != "" {
|
||||||
|
if port := 0; len(v) > 0 {
|
||||||
|
fmt.Sscanf(v, "%d", &port)
|
||||||
|
if port > 0 {
|
||||||
|
cfg.Database.Port = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("RMM_DB_USER"); v != "" {
|
||||||
|
cfg.Database.User = v
|
||||||
|
}
|
||||||
if v := os.Getenv("RMM_DB_PASSWORD"); v != "" {
|
if v := os.Getenv("RMM_DB_PASSWORD"); v != "" {
|
||||||
cfg.Database.Password = v
|
cfg.Database.Password = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("RMM_DB_NAME"); v != "" {
|
||||||
|
cfg.Database.DBName = v
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,7 +140,6 @@ func (d *Database) migrate() error {
|
|||||||
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS agent_version TEXT NOT NULL DEFAULT ''")
|
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS agent_version TEXT NOT NULL DEFAULT ''")
|
||||||
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE")
|
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS update_requested BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'freebsd'")
|
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'freebsd'")
|
||||||
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS last_api_key TEXT NOT NULL DEFAULT ''")
|
|
||||||
|
|
||||||
// Agent-Firmware Tabelle (Multi-Plattform)
|
// Agent-Firmware Tabelle (Multi-Plattform)
|
||||||
d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware (
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS agent_firmware (
|
||||||
@ -202,11 +201,6 @@ func (d *Database) migrate() error {
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
last_used TIMESTAMPTZ
|
last_used TIMESTAMPTZ
|
||||||
)`)
|
)`)
|
||||||
// Migration: customer_id + permissions Spalten hinzufuegen falls nicht vorhanden
|
|
||||||
d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL`)
|
|
||||||
d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS permissions TEXT NOT NULL DEFAULT 'admin'`)
|
|
||||||
// Bestehende Keys auf 'admin' setzen (Abwaertskompatibilitaet)
|
|
||||||
d.db.Exec(`UPDATE api_keys SET permissions = 'admin' WHERE permissions = ''`)
|
|
||||||
|
|
||||||
// Installer-Pakete (install.sh + Plugin als ZIP pro Plattform)
|
// Installer-Pakete (install.sh + Plugin als ZIP pro Plattform)
|
||||||
d.db.Exec(`CREATE TABLE IF NOT EXISTS installers (
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS installers (
|
||||||
@ -280,18 +274,6 @@ func (d *Database) migrate() error {
|
|||||||
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
|
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
|
||||||
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
|
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
|
||||||
|
|
||||||
// WAU-Regeln pro Kunde
|
|
||||||
d.db.Exec(`CREATE TABLE IF NOT EXISTS customer_wau_rules (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
winget_id TEXT NOT NULL,
|
|
||||||
display_name TEXT NOT NULL DEFAULT '',
|
|
||||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('allow', 'block')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE(customer_id, winget_id)
|
|
||||||
)`)
|
|
||||||
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_wau_rules_customer ON customer_wau_rules(customer_id, rule_type)`)
|
|
||||||
|
|
||||||
// Retention Policy (90 Tage)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
@ -366,52 +348,6 @@ func (d *Database) RegisterAgent(agent *models.Agent) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveSystemDataRaw speichert rohe JSON-Systemdaten (plattformunabhaengig)
|
|
||||||
func (d *Database) SaveSystemDataRaw(agentID string, raw json.RawMessage) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Leeres JSON als leeres Objekt behandeln
|
|
||||||
if len(raw) == 0 {
|
|
||||||
raw = json.RawMessage("{}")
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := d.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
res, err := tx.Exec("UPDATE agents SET last_heartbeat = $1 WHERE id = $2", now, agentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows, _ := res.RowsAffected()
|
|
||||||
if rows == 0 {
|
|
||||||
return fmt.Errorf("Agent %s nicht gefunden", agentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec(`
|
|
||||||
INSERT INTO system_data (agent_id, data_json, updated_at)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT(agent_id) DO UPDATE SET
|
|
||||||
data_json=EXCLUDED.data_json,
|
|
||||||
updated_at=EXCLUDED.updated_at
|
|
||||||
`, agentID, string(raw), now)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metriken nur fuer OPNsense/Linux (haben bekannte Struct-Form)
|
|
||||||
var sd models.SystemData
|
|
||||||
if err2 := json.Unmarshal(raw, &sd); err2 == nil && sd.CPU.Threads > 0 {
|
|
||||||
if err2 := d.writeMetrics(tx, agentID, now, &sd); err2 != nil {
|
|
||||||
log.Printf("Metriken schreiben Warnung: %v", err2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error {
|
func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
@ -550,7 +486,7 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data *
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetAgents() ([]models.Agent, error) {
|
func (d *Database) GetAgents() ([]models.Agent, error) {
|
||||||
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, agent_version, platform, registered_at, last_heartbeat, customer_id, update_requested, last_api_key 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
|
||||||
}
|
}
|
||||||
@ -562,7 +498,7 @@ func (d *Database) GetAgents() ([]models.Agent, error) {
|
|||||||
var lastHB sql.NullTime
|
var lastHB sql.NullTime
|
||||||
var custID sql.NullInt64
|
var custID sql.NullInt64
|
||||||
|
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.AgentVersion, &a.Platform, &a.RegisteredAt, &lastHB, &custID, &a.UpdateRequested, &a.LastAPIKey); 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 {
|
||||||
@ -646,7 +582,7 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error
|
|||||||
a.CustomerID = &cid
|
a.CustomerID = &cid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Systemdaten laden (raw JSON — plattformunabhaengig)
|
// Systemdaten laden
|
||||||
var dataJSON sql.NullString
|
var dataJSON sql.NullString
|
||||||
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = $1", id).Scan(&dataJSON)
|
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = $1", id).Scan(&dataJSON)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
@ -654,10 +590,11 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sysData *models.SystemData
|
var sysData *models.SystemData
|
||||||
if dataJSON.Valid && dataJSON.String != "" {
|
if dataJSON.Valid {
|
||||||
sysData = &models.SystemData{}
|
sysData = &models.SystemData{}
|
||||||
json.Unmarshal([]byte(dataJSON.String), sysData)
|
if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil {
|
||||||
sysData.RawJSON = json.RawMessage(dataJSON.String)
|
return &a, nil, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &a, sysData, nil
|
return &a, sysData, nil
|
||||||
@ -685,10 +622,6 @@ func (d *Database) UpdateAgentVersion(id, version string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) UpdateAgentAPIKey(id, apiKey string) {
|
|
||||||
d.db.Exec("UPDATE agents SET last_api_key = $1 WHERE id = $2", apiKey, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update-Request Flag setzen/lesen/loeschen
|
// Update-Request Flag setzen/lesen/loeschen
|
||||||
func (d *Database) SetUpdateRequest(id string, requested bool) error {
|
func (d *Database) SetUpdateRequest(id string, requested bool) error {
|
||||||
_, err := d.db.Exec("UPDATE agents SET update_requested = $1 WHERE id = $2", requested, id)
|
_, err := d.db.Exec("UPDATE agents SET update_requested = $1 WHERE id = $2", requested, id)
|
||||||
@ -1160,19 +1093,19 @@ func (d *Database) UnassignAgentCustomer(agentID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetAgentsByCustomer(customerID int) ([]models.AgentResponse, error) {
|
func (d *Database) GetAgentsByCustomer(customerID int) ([]models.Agent, error) {
|
||||||
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id, agent_version, last_api_key FROM agents WHERE customer_id = $1 ORDER BY name", customerID)
|
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents WHERE customer_id = $1 ORDER BY name", customerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var agents []models.AgentResponse
|
var agents []models.Agent
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.Agent
|
var a models.Agent
|
||||||
var lastHB sql.NullTime
|
var lastHB sql.NullTime
|
||||||
var custID sql.NullInt64
|
var custID sql.NullInt64
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID, &a.AgentVersion, &a.LastAPIKey); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastHB.Valid {
|
if lastHB.Valid {
|
||||||
@ -1182,13 +1115,10 @@ func (d *Database) GetAgentsByCustomer(customerID int) ([]models.AgentResponse,
|
|||||||
cid := int(custID.Int64)
|
cid := int(custID.Int64)
|
||||||
a.CustomerID = &cid
|
a.CustomerID = &cid
|
||||||
}
|
}
|
||||||
agents = append(agents, models.AgentResponse{
|
agents = append(agents, a)
|
||||||
Agent: a,
|
|
||||||
Status: models.CalculateAgentStatus(a.LastHeartbeat),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if agents == nil {
|
if agents == nil {
|
||||||
agents = []models.AgentResponse{}
|
agents = []models.Agent{}
|
||||||
}
|
}
|
||||||
return agents, rows.Err()
|
return agents, rows.Err()
|
||||||
}
|
}
|
||||||
@ -1205,23 +1135,16 @@ func (d *Database) GetAgentStatus(agentID string) string {
|
|||||||
|
|
||||||
// ==================== API-Keys ====================
|
// ==================== API-Keys ====================
|
||||||
|
|
||||||
// Permissions: "agent" | "read" | "write" | "admin"
|
|
||||||
// - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat (fuer Agents auf Kundenservern)
|
|
||||||
// - read: GET-Endpoints, optional auf customer_id beschraenkt
|
|
||||||
// - write: alles ausser Terminal (fuer externe Integrationen)
|
|
||||||
// - admin: voll (kein Terminal — das ist JWT-only)
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Permissions string `json:"permissions"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
CustomerID *int `json:"customer_id,omitempty"`
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
||||||
rows, err := d.db.Query("SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys ORDER BY created_at")
|
rows, err := d.db.Query("SELECT id, name, key, created_at, last_used FROM api_keys ORDER BY created_at")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -1231,17 +1154,12 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var k APIKey
|
var k APIKey
|
||||||
var lastUsed sql.NullTime
|
var lastUsed sql.NullTime
|
||||||
var custID sql.NullInt64
|
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt, &lastUsed); err != nil {
|
||||||
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastUsed.Valid {
|
if lastUsed.Valid {
|
||||||
k.LastUsed = &lastUsed.Time
|
k.LastUsed = &lastUsed.Time
|
||||||
}
|
}
|
||||||
if custID.Valid {
|
|
||||||
id := int(custID.Int64)
|
|
||||||
k.CustomerID = &id
|
|
||||||
}
|
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
if keys == nil {
|
if keys == nil {
|
||||||
@ -1250,24 +1168,15 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
|||||||
return keys, rows.Err()
|
return keys, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) CreateAPIKey(name, key, permissions string, customerID *int) (*APIKey, error) {
|
func (d *Database) CreateAPIKey(name, key string) (*APIKey, error) {
|
||||||
var k APIKey
|
var k APIKey
|
||||||
var custID sql.NullInt64
|
|
||||||
if customerID != nil {
|
|
||||||
custID = sql.NullInt64{Int64: int64(*customerID), Valid: true}
|
|
||||||
}
|
|
||||||
var scannedCustID sql.NullInt64
|
|
||||||
err := d.db.QueryRow(
|
err := d.db.QueryRow(
|
||||||
"INSERT INTO api_keys (name, key, permissions, customer_id) VALUES ($1, $2, $3, $4) RETURNING id, name, key, permissions, customer_id, created_at",
|
"INSERT INTO api_keys (name, key) VALUES ($1, $2) RETURNING id, name, key, created_at",
|
||||||
name, key, permissions, custID,
|
name, key,
|
||||||
).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &scannedCustID, &k.CreatedAt)
|
).Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if scannedCustID.Valid {
|
|
||||||
id := int(scannedCustID.Int64)
|
|
||||||
k.CustomerID = &id
|
|
||||||
}
|
|
||||||
return &k, nil
|
return &k, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1283,25 +1192,6 @@ func (d *Database) DeleteAPIKey(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAPIKeyInfo gibt Key + Permissions + CustomerID zurueck (fuer Middleware)
|
|
||||||
func (d *Database) GetAPIKeyInfo(key string) (*APIKey, error) {
|
|
||||||
var k APIKey
|
|
||||||
var custID sql.NullInt64
|
|
||||||
var lastUsed sql.NullTime
|
|
||||||
err := d.db.QueryRow(
|
|
||||||
"SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE key = $1",
|
|
||||||
key,
|
|
||||||
).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if custID.Valid {
|
|
||||||
id := int(custID.Int64)
|
|
||||||
k.CustomerID = &id
|
|
||||||
}
|
|
||||||
return &k, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Database) GetAllAPIKeys() ([]string, error) {
|
func (d *Database) GetAllAPIKeys() ([]string, error) {
|
||||||
rows, err := d.db.Query("SELECT key FROM api_keys")
|
rows, err := d.db.Query("SELECT key FROM api_keys")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1324,38 +1214,6 @@ func (d *Database) UpdateAPIKeyLastUsed(key string) {
|
|||||||
d.db.Exec("UPDATE api_keys SET last_used = NOW() WHERE key = $1", key)
|
d.db.Exec("UPDATE api_keys SET last_used = NOW() WHERE key = $1", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) ListCustomerAPIKeys(customerID int) ([]APIKey, error) {
|
|
||||||
rows, err := d.db.Query(
|
|
||||||
"SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE customer_id = $1 ORDER BY permissions, created_at",
|
|
||||||
customerID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var keys []APIKey
|
|
||||||
for rows.Next() {
|
|
||||||
var k APIKey
|
|
||||||
var lastUsed sql.NullTime
|
|
||||||
var custID sql.NullInt64
|
|
||||||
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if lastUsed.Valid {
|
|
||||||
k.LastUsed = &lastUsed.Time
|
|
||||||
}
|
|
||||||
if custID.Valid {
|
|
||||||
id := int(custID.Int64)
|
|
||||||
k.CustomerID = &id
|
|
||||||
}
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
if keys == nil {
|
|
||||||
keys = []APIKey{}
|
|
||||||
}
|
|
||||||
return keys, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig)
|
// MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig)
|
||||||
// --- Audit Log ---
|
// --- Audit Log ---
|
||||||
|
|
||||||
|
|||||||
@ -1,350 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/cynfo/rmm-backend/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ListWAURules gibt alle WAU-Regeln eines Kunden zurück
|
|
||||||
func (d *Database) ListWAURules(customerID int) ([]models.WAURule, error) {
|
|
||||||
rows, err := d.db.Query(`
|
|
||||||
SELECT id, customer_id, winget_id, display_name, rule_type, created_at
|
|
||||||
FROM customer_wau_rules
|
|
||||||
WHERE customer_id = $1
|
|
||||||
ORDER BY rule_type, lower(display_name)
|
|
||||||
`, customerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var rules []models.WAURule
|
|
||||||
for rows.Next() {
|
|
||||||
var r models.WAURule
|
|
||||||
if err := rows.Scan(&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rules = append(rules, r)
|
|
||||||
}
|
|
||||||
if rules == nil {
|
|
||||||
rules = []models.WAURule{}
|
|
||||||
}
|
|
||||||
return rules, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddWAURule fügt eine neue Regel hinzu (upsert auf winget_id)
|
|
||||||
func (d *Database) AddWAURule(customerID int, wingetID, displayName, ruleType string) (*models.WAURule, error) {
|
|
||||||
var r models.WAURule
|
|
||||||
err := d.db.QueryRow(`
|
|
||||||
INSERT INTO customer_wau_rules (customer_id, winget_id, display_name, rule_type)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (customer_id, winget_id) DO UPDATE
|
|
||||||
SET display_name = EXCLUDED.display_name,
|
|
||||||
rule_type = EXCLUDED.rule_type
|
|
||||||
RETURNING id, customer_id, winget_id, display_name, rule_type, created_at
|
|
||||||
`, customerID, wingetID, displayName, ruleType).Scan(
|
|
||||||
&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteWAURule löscht eine Regel (per ID, gehört zu customerID)
|
|
||||||
func (d *Database) DeleteWAURule(customerID, ruleID int) (bool, error) {
|
|
||||||
res, err := d.db.Exec(`
|
|
||||||
DELETE FROM customer_wau_rules WHERE id = $1 AND customer_id = $2
|
|
||||||
`, ruleID, customerID)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return n > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWAUList gibt alle winget-IDs eines bestimmten Typs zurück (für den Plaintext-Endpoint)
|
|
||||||
func (d *Database) GetWAUList(customerID int, ruleType string) ([]string, error) {
|
|
||||||
rows, err := d.db.Query(`
|
|
||||||
SELECT winget_id FROM customer_wau_rules
|
|
||||||
WHERE customer_id = $1 AND rule_type = $2
|
|
||||||
ORDER BY lower(winget_id)
|
|
||||||
`, customerID, ruleType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWAUSoftwareInventory aggregiert installierte Software aller Windows-Agents eines Kunden
|
|
||||||
func (d *Database) GetWAUSoftwareInventory(customerID int) ([]models.WAUSoftwareEntry, error) {
|
|
||||||
rows, err := d.db.Query(`
|
|
||||||
SELECT
|
|
||||||
app->>'name' AS name,
|
|
||||||
COALESCE(app->>'publisher', '') AS publisher,
|
|
||||||
COUNT(DISTINCT sd.agent_id) AS device_count,
|
|
||||||
jsonb_agg(DISTINCT app->>'version') FILTER (WHERE (app->>'version') != '') AS versions
|
|
||||||
FROM agents a
|
|
||||||
JOIN system_data sd ON sd.agent_id = a.id
|
|
||||||
CROSS JOIN jsonb_array_elements(sd.data_json->'windows'->'installed_software') AS app
|
|
||||||
WHERE a.customer_id = $1
|
|
||||||
AND sd.data_json ? 'windows'
|
|
||||||
AND jsonb_typeof(sd.data_json->'windows'->'installed_software') = 'array'
|
|
||||||
AND (app->>'name') IS NOT NULL
|
|
||||||
AND (app->>'name') != ''
|
|
||||||
GROUP BY app->>'name', app->>'publisher'
|
|
||||||
ORDER BY lower(app->>'name')
|
|
||||||
`, customerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var entries []models.WAUSoftwareEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var e models.WAUSoftwareEntry
|
|
||||||
var versionsJSON sql.NullString
|
|
||||||
|
|
||||||
if err := rows.Scan(&e.Name, &e.Publisher, &e.DeviceCount, &versionsJSON); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if versionsJSON.Valid && versionsJSON.String != "" && versionsJSON.String != "null" {
|
|
||||||
json.Unmarshal([]byte(versionsJSON.String), &e.Versions)
|
|
||||||
}
|
|
||||||
if e.Versions == nil {
|
|
||||||
e.Versions = []string{}
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
if entries == nil {
|
|
||||||
entries = []models.WAUSoftwareEntry{}
|
|
||||||
}
|
|
||||||
return entries, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWAUCompliance berechnet den Compliance-Status aller Windows-Agents eines Kunden
|
|
||||||
// Prüft: WAU installiert, URLs korrekt, alle allow-Pakete installiert
|
|
||||||
func (d *Database) GetWAUCompliance(customerID int, expectedIncludeURL, expectedExcludeURL string) ([]models.WAUAgentCompliance, error) {
|
|
||||||
// Alle Windows-Agents des Kunden mit ihren system_data holen
|
|
||||||
rows, err := d.db.Query(`
|
|
||||||
SELECT a.id, a.hostname, sd.data_json
|
|
||||||
FROM agents a
|
|
||||||
JOIN system_data sd ON sd.agent_id = a.id
|
|
||||||
WHERE a.customer_id = $1
|
|
||||||
AND sd.data_json ? 'windows'
|
|
||||||
ORDER BY lower(a.hostname)
|
|
||||||
`, customerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
// Allow-Pakete (= Required) für diesen Kunden laden
|
|
||||||
allowRules, err := d.GetWAUList(customerID, "allow")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
allowRulesFull, err := d.ListWAURules(customerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Map winget_id → display_name
|
|
||||||
displayNames := make(map[string]string)
|
|
||||||
for _, r := range allowRulesFull {
|
|
||||||
if r.RuleType == "allow" {
|
|
||||||
displayNames[r.WingetID] = r.DisplayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []models.WAUAgentCompliance
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var agentID, hostname string
|
|
||||||
var dataJSON []byte
|
|
||||||
if err := rows.Scan(&agentID, &hostname, &dataJSON); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON parsen (nur was wir brauchen)
|
|
||||||
var data struct {
|
|
||||||
Windows struct {
|
|
||||||
InstalledSoftware []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
} `json:"installed_software"`
|
|
||||||
WAU *struct {
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
IncludeURL string `json:"include_url"`
|
|
||||||
ExcludeURL string `json:"exclude_url"`
|
|
||||||
PendingCount int `json:"pending_updates"`
|
|
||||||
} `json:"wau"`
|
|
||||||
} `json:"windows"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(dataJSON, &data); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ac := models.WAUAgentCompliance{
|
|
||||||
AgentID: agentID,
|
|
||||||
AgentName: hostname,
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAU-Status
|
|
||||||
if data.Windows.WAU != nil {
|
|
||||||
wau := data.Windows.WAU
|
|
||||||
ac.WAUInstalled = wau.Installed
|
|
||||||
ac.IncludeURL = wau.IncludeURL
|
|
||||||
ac.ExcludeURL = wau.ExcludeURL
|
|
||||||
ac.PendingCount = wau.PendingCount
|
|
||||||
|
|
||||||
// URL-Check: beide URLs müssen gesetzt sein und passen
|
|
||||||
includeOK := expectedIncludeURL == "" || wau.IncludeURL == expectedIncludeURL
|
|
||||||
excludeOK := expectedExcludeURL == "" || wau.ExcludeURL == expectedExcludeURL
|
|
||||||
ac.WAUConfigOK = wau.Installed && includeOK && excludeOK
|
|
||||||
}
|
|
||||||
|
|
||||||
// Installierte Software als lowercase-Set für schnelles Lookup
|
|
||||||
installedSet := make(map[string]bool)
|
|
||||||
for _, s := range data.Windows.InstalledSoftware {
|
|
||||||
installedSet[strings.ToLower(s.Name)] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro allow-Paket prüfen ob installiert
|
|
||||||
for _, wingetID := range allowRules {
|
|
||||||
displayName := displayNames[wingetID]
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = wingetID
|
|
||||||
}
|
|
||||||
pkg := models.WAUPackageCompliance{
|
|
||||||
WingetID: wingetID,
|
|
||||||
DisplayName: displayName,
|
|
||||||
}
|
|
||||||
// Fuzzy-Match: display_name (lowercase) muss in installierter Software enthalten sein
|
|
||||||
lowerDisplay := strings.ToLower(displayName)
|
|
||||||
for installedName := range installedSet {
|
|
||||||
if strings.Contains(installedName, lowerDisplay) ||
|
|
||||||
strings.Contains(lowerDisplay, installedName) {
|
|
||||||
pkg.Installed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Winget-ID-Teile als Fallback (z.B. "Mozilla.Firefox" → "firefox")
|
|
||||||
if !pkg.Installed {
|
|
||||||
parts := strings.Split(strings.ToLower(wingetID), ".")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
appName := parts[len(parts)-1]
|
|
||||||
for installedName := range installedSet {
|
|
||||||
if strings.Contains(installedName, appName) {
|
|
||||||
pkg.Installed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ac.Packages = append(ac.Packages, pkg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gesamt-Compliance: WAU installiert + korrekt + alle Pakete da + keine Updates ausstehend
|
|
||||||
allInstalled := true
|
|
||||||
for _, p := range ac.Packages {
|
|
||||||
if !p.Installed {
|
|
||||||
allInstalled = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ac.Compliant = ac.WAUInstalled && ac.WAUConfigOK && allInstalled && ac.PendingCount == 0
|
|
||||||
|
|
||||||
result = append(result, ac)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = []models.WAUAgentCompliance{}
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWAUComplianceSummary gibt eine kompakte Zusammenfassung für die Kundenliste zurück
|
|
||||||
func (d *Database) GetWAUComplianceSummary(customerID int, expectedIncludeURL, expectedExcludeURL string) (*models.WAUComplianceSummary, error) {
|
|
||||||
agents, err := d.GetWAUCompliance(customerID, expectedIncludeURL, expectedExcludeURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := &models.WAUComplianceSummary{
|
|
||||||
CustomerID: customerID,
|
|
||||||
TotalAgents: len(agents),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range agents {
|
|
||||||
if a.Compliant {
|
|
||||||
summary.CompliantAgents++
|
|
||||||
}
|
|
||||||
if !a.WAUInstalled {
|
|
||||||
summary.WAUMissing++
|
|
||||||
} else if !a.WAUConfigOK {
|
|
||||||
summary.WAUMisconfigured++
|
|
||||||
}
|
|
||||||
if a.PendingCount > 0 {
|
|
||||||
summary.UpdatesPending++
|
|
||||||
}
|
|
||||||
for _, p := range a.Packages {
|
|
||||||
if !p.Installed {
|
|
||||||
summary.PackagesMissing++
|
|
||||||
break // pro Agent nur einmal zählen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status ableiten
|
|
||||||
if summary.WAUMissing > 0 || summary.PackagesMissing > 0 {
|
|
||||||
summary.Status = "critical"
|
|
||||||
} else if summary.WAUMisconfigured > 0 || summary.UpdatesPending > 0 {
|
|
||||||
summary.Status = "warning"
|
|
||||||
} else if summary.TotalAgents == 0 {
|
|
||||||
summary.Status = "unknown"
|
|
||||||
} else {
|
|
||||||
summary.Status = "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllCustomersWAUSummary gibt Compliance-Summary für alle Kunden zurück
|
|
||||||
func (d *Database) GetAllCustomersWAUSummary(includeURL, excludeURL string) ([]models.WAUComplianceSummary, error) {
|
|
||||||
rows, err := d.db.Query(`SELECT id FROM customers ORDER BY name`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var summaries []models.WAUComplianceSummary
|
|
||||||
for rows.Next() {
|
|
||||||
var id int
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s, err := d.GetWAUComplianceSummary(id, includeURL, excludeURL)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s.TotalAgents > 0 {
|
|
||||||
summaries = append(summaries, *s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if summaries == nil {
|
|
||||||
summaries = []models.WAUComplianceSummary{}
|
|
||||||
}
|
|
||||||
return summaries, rows.Err()
|
|
||||||
}
|
|
||||||
@ -93,12 +93,6 @@ func main() {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("POST /api/v1/auth/login", authMux)
|
mux.Handle("POST /api/v1/auth/login", authMux)
|
||||||
mux.Handle("POST /api/v1/auth/2fa/validate", authMux) // 2FA-Schritt 2 ebenfalls ohne JWT
|
mux.Handle("POST /api/v1/auth/2fa/validate", authMux) // 2FA-Schritt 2 ebenfalls ohne JWT
|
||||||
|
|
||||||
// Terminal: JWT-only (kein API-Key reicht — erfordert echten Login mit 2FA)
|
|
||||||
terminalMux := http.NewServeMux()
|
|
||||||
terminalMux.HandleFunc("GET /api/v1/agents/{id}/terminal", handler.TerminalHandler(hub))
|
|
||||||
mux.Handle("GET /api/v1/agents/{id}/terminal", api.JWTOnlyAuth(authHandler, terminalMux))
|
|
||||||
|
|
||||||
mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux))
|
mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux))
|
||||||
|
|
||||||
// Middleware-Chain: CORS -> Logging -> Handler
|
// Middleware-Chain: CORS -> Logging -> Handler
|
||||||
@ -157,7 +151,7 @@ func ensureTLS(certPath, keyPath string) error {
|
|||||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
BasicConstraintsValid: true,
|
BasicConstraintsValid: true,
|
||||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("192.168.85.13")},
|
IPAddresses: localIPs(),
|
||||||
DNSNames: []string{"localhost", "rmm-backend"},
|
DNSNames: []string{"localhost", "rmm-backend"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,3 +182,28 @@ func ensureTLS(certPath, keyPath string) error {
|
|||||||
log.Printf("TLS-Zertifikat generiert: %s, %s", certPath, keyPath)
|
log.Printf("TLS-Zertifikat generiert: %s, %s", certPath, keyPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localIPs sammelt alle lokalen IP-Adressen des Hosts für das TLS-Zertifikat
|
||||||
|
func localIPs() []net.IP {
|
||||||
|
ips := []net.IP{net.ParseIP("127.0.0.1")}
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
addrs, _ := iface.Addrs()
|
||||||
|
for _, addr := range addrs {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := addr.(type) {
|
||||||
|
case *net.IPNet:
|
||||||
|
ip = v.IP
|
||||||
|
case *net.IPAddr:
|
||||||
|
ip = v.IP
|
||||||
|
}
|
||||||
|
if ip != nil && !ip.IsLoopback() && ip.To4() != nil {
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ type Agent struct {
|
|||||||
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
|
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
|
||||||
CustomerID *int `json:"customer_id,omitempty"`
|
CustomerID *int `json:"customer_id,omitempty"`
|
||||||
UpdateRequested bool `json:"update_requested"`
|
UpdateRequested bool `json:"update_requested"`
|
||||||
LastAPIKey string `json:"last_api_key,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentResponse erweitert Agent um den berechneten Status
|
// AgentResponse erweitert Agent um den berechneten Status
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "encoding/json"
|
|
||||||
|
|
||||||
// SystemData - Alle Systemdaten die der Agent sendet
|
// SystemData - Alle Systemdaten die der Agent sendet
|
||||||
type SystemData struct {
|
type SystemData struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
@ -31,10 +29,6 @@ type SystemData struct {
|
|||||||
PFStates *PFStatesInfo `json:"pf_states,omitempty"`
|
PFStates *PFStatesInfo `json:"pf_states,omitempty"`
|
||||||
HAProxy *HAProxyData `json:"haproxy,omitempty"`
|
HAProxy *HAProxyData `json:"haproxy,omitempty"`
|
||||||
Caddy *CaddyData `json:"caddy,omitempty"`
|
Caddy *CaddyData `json:"caddy,omitempty"`
|
||||||
// Plattform-spezifische Rohdaten (Windows, Linux, etc.)
|
|
||||||
Windows json.RawMessage `json:"windows,omitempty"`
|
|
||||||
// RawJSON: vollstaendige Rohdaten aus DB (wird im API-Response verwendet)
|
|
||||||
RawJSON json.RawMessage `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LicenseInfo struct {
|
type LicenseInfo struct {
|
||||||
@ -297,10 +291,10 @@ type CaddyData struct {
|
|||||||
|
|
||||||
// HeartbeatRequest
|
// HeartbeatRequest
|
||||||
type HeartbeatRequest struct {
|
type HeartbeatRequest struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
AgentVersion string `json:"agent_version,omitempty"`
|
AgentVersion string `json:"agent_version,omitempty"`
|
||||||
Platform string `json:"platform,omitempty"`
|
Platform string `json:"platform,omitempty"`
|
||||||
SystemData json.RawMessage `json:"system_data"`
|
SystemData SystemData `json:"system_data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeartbeatResponse
|
// HeartbeatResponse
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type WAURule struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
CustomerID int `json:"customer_id"`
|
|
||||||
WingetID string `json:"winget_id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
RuleType string `json:"rule_type"` // "allow" | "block"
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WAUSoftwareEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Publisher string `json:"publisher"`
|
|
||||||
DeviceCount int `json:"device_count"`
|
|
||||||
Versions []string `json:"versions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAUPackageCompliance beschreibt den Compliance-Status eines einzelnen Pakets auf einem Gerät
|
|
||||||
type WAUPackageCompliance struct {
|
|
||||||
WingetID string `json:"winget_id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
HasUpdates bool `json:"has_updates"` // true wenn PendingCount > 0 und dieses Paket betroffen
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAUAgentCompliance beschreibt den vollständigen WAU-Compliance-Status eines Agents
|
|
||||||
type WAUAgentCompliance struct {
|
|
||||||
AgentID string `json:"agent_id"`
|
|
||||||
AgentName string `json:"agent_name"`
|
|
||||||
WAUInstalled bool `json:"wau_installed"`
|
|
||||||
WAUConfigOK bool `json:"wau_config_ok"` // Include/Exclude-URLs korrekt gesetzt
|
|
||||||
IncludeURL string `json:"include_url"` // tatsächlich konfigurierte URL
|
|
||||||
ExcludeURL string `json:"exclude_url"` // tatsächlich konfigurierte URL
|
|
||||||
PendingCount int `json:"pending_updates"`
|
|
||||||
Packages []WAUPackageCompliance `json:"packages"`
|
|
||||||
Compliant bool `json:"compliant"` // true wenn alles OK
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAUComplianceSummary fasst den Compliance-Status eines Kunden zusammen (für die Kundenliste)
|
|
||||||
type WAUComplianceSummary struct {
|
|
||||||
CustomerID int `json:"customer_id"`
|
|
||||||
TotalAgents int `json:"total_agents"`
|
|
||||||
CompliantAgents int `json:"compliant_agents"`
|
|
||||||
WAUMissing int `json:"wau_missing"` // Agents ohne WAU
|
|
||||||
WAUMisconfigured int `json:"wau_misconfigured"` // WAU installiert, aber falsche URLs
|
|
||||||
PackagesMissing int `json:"packages_missing"` // Agents mit fehlenden Pflichtpaketen
|
|
||||||
UpdatesPending int `json:"updates_pending"` // Agents mit ausstehenden Updates
|
|
||||||
Status string `json:"status"` // "ok" | "warning" | "critical"
|
|
||||||
}
|
|
||||||
@ -1,14 +1,24 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Frontend Build & Deploy
|
||||||
|
# Umgebungsvariablen setzen:
|
||||||
|
# REMOTE=root@<FRONTEND-SERVER>
|
||||||
|
# VITE_BACKEND_HOST=<BACKEND-IP-ODER-HOSTNAME>
|
||||||
|
# VITE_BACKEND_PORT=8443 (optional, default: 8443)
|
||||||
|
# VITE_API_KEY=<API-KEY> (optional)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
FRONTEND_DIR="$(dirname "$0")/frontend"
|
|
||||||
REMOTE="root@192.168.85.20"
|
|
||||||
WEB_ROOT="/var/www/html"
|
|
||||||
|
|
||||||
echo "Building..."
|
REMOTE="${REMOTE:-root@localhost}"
|
||||||
cd "$FRONTEND_DIR" && npm run build
|
WEBROOT="${WEBROOT:-/var/www/html}"
|
||||||
|
|
||||||
echo "Deploying..."
|
echo "==> Baue Frontend..."
|
||||||
ssh "$REMOTE" "rm -rf ${WEB_ROOT}/assets/*"
|
VITE_BACKEND_HOST="${VITE_BACKEND_HOST}" \
|
||||||
scp -r dist/* "$REMOTE:${WEB_ROOT}/"
|
VITE_BACKEND_PORT="${VITE_BACKEND_PORT:-8443}" \
|
||||||
ssh "$REMOTE" "chmod -R 755 ${WEB_ROOT}/assets && chmod 644 ${WEB_ROOT}/assets/* ${WEB_ROOT}/index.html ${WEB_ROOT}/vite.svg 2>/dev/null; nginx -s reload"
|
VITE_API_KEY="${VITE_API_KEY}" \
|
||||||
echo "Done."
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Deploye nach $REMOTE:$WEBROOT ..."
|
||||||
|
ssh "$REMOTE" "rm -rf ${WEBROOT}/assets/*"
|
||||||
|
scp -r dist/assets dist/index.html "$REMOTE:${WEBROOT}/"
|
||||||
|
ssh "$REMOTE" "chown -R www-data:www-data ${WEBROOT}/ && chmod -R 755 ${WEBROOT}/"
|
||||||
|
echo "==> Fertig"
|
||||||
|
|||||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb:latest-pg17
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: rmm
|
||||||
|
POSTGRES_USER: rmm
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U rmm"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8443:8443"
|
||||||
|
environment:
|
||||||
|
RMM_DB_HOST: db
|
||||||
|
RMM_DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
RMM_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
RMM_API_KEY: ${API_KEY}
|
||||||
|
volumes:
|
||||||
|
- certs:/app/certs
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
environment:
|
||||||
|
BACKEND_HOST: ${BACKEND_HOST}
|
||||||
|
BACKEND_PORT: ${BACKEND_PORT:-8443}
|
||||||
|
API_KEY: ${API_KEY}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
certs:
|
||||||
@ -291,7 +291,7 @@ Siehe [FIRMWARE.md](FIRMWARE.md) fuer Details.
|
|||||||
# Peer anlegen
|
# Peer anlegen
|
||||||
curl -sk -H "X-API-Key: YOUR_API_KEY" -X POST \
|
curl -sk -H "X-API-Key: YOUR_API_KEY" -X POST \
|
||||||
https://your-backend:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers \
|
https://your-backend:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers \
|
||||||
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}'
|
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"<DNS-SERVER>"}'
|
||||||
|
|
||||||
# Peer mit eigenem Endpoint
|
# Peer mit eigenem Endpoint
|
||||||
curl -sk -H "X-API-Key: YOUR_API_KEY" -X POST \
|
curl -sk -H "X-API-Key: YOUR_API_KEY" -X POST \
|
||||||
|
|||||||
@ -620,7 +620,7 @@ Peer-Anlage: Keypair generiert auf Firewall, naechste freie IP automatisch, Endp
|
|||||||
# Peer anlegen
|
# Peer anlegen
|
||||||
curl -sk -H "X-API-Key: YOUR_API_KEY" -X POST \
|
curl -sk -H "X-API-Key: YOUR_API_KEY" -X POST \
|
||||||
https://your-backend:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers \
|
https://your-backend:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers \
|
||||||
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}'
|
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"<DNS-SERVER>"}'
|
||||||
|
|
||||||
# Alle Peers
|
# Alle Peers
|
||||||
curl -sk -H "X-API-Key: YOUR_API_KEY" \
|
curl -sk -H "X-API-Key: YOUR_API_KEY" \
|
||||||
|
|||||||
221
docs/DOCKER.md
Normal file
221
docs/DOCKER.md
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# Docker Installation
|
||||||
|
|
||||||
|
Diese Anleitung erklärt, wie du das RMM-System mit Docker auf einem Linux-Server installierst.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Linux-Server (Debian 12 / Ubuntu 22.04 oder neuer empfohlen)
|
||||||
|
- Mindestens 2 GB RAM, 10 GB freier Speicherplatz
|
||||||
|
- Docker und Docker Compose installiert
|
||||||
|
|
||||||
|
**Docker installieren** (falls noch nicht vorhanden):
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das war es. Docker Compose ist seit Docker 2.x automatisch dabei (`docker compose`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Schritt 1 — Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.cynfo.net/christian/rmm2.git
|
||||||
|
cd rmm2
|
||||||
|
git checkout release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 2 — Konfigurationsdatei anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne `.env` mit einem Texteditor und trage deine Werte ein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Datei sieht so aus:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# IP-Adresse oder Hostname dieses Servers
|
||||||
|
# (die Adresse über die du später im Browser das Frontend erreichst)
|
||||||
|
BACKEND_HOST=192.168.1.100
|
||||||
|
|
||||||
|
# Port des Backends — 8443 ist der Standard, nur ändern wenn nötig
|
||||||
|
BACKEND_PORT=8443
|
||||||
|
|
||||||
|
# Datenbank-Passwort — frei wählbar, wird nur intern verwendet
|
||||||
|
DB_PASSWORD=einSicheresPasswort123
|
||||||
|
|
||||||
|
# JWT-Secret — langer zufälliger String, mindestens 32 Zeichen
|
||||||
|
# Tipp: openssl rand -hex 32
|
||||||
|
JWT_SECRET=hierEinenLangenZufaelligenStringEintragen
|
||||||
|
|
||||||
|
# API-Key — frei wählbar, wird vom Frontend und den Agents verwendet
|
||||||
|
# Tipp: openssl rand -hex 16
|
||||||
|
API_KEY=hierEinenApiKeyEintragen
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Wichtig:** `BACKEND_HOST` ist die IP-Adresse oder der Hostname des Servers auf dem
|
||||||
|
> Docker läuft — nicht `localhost`. Der Browser muss diese Adresse erreichen können,
|
||||||
|
> um mit dem Backend zu kommunizieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 3 — Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start lädt Docker die benötigten Basis-Images (postgres, nginx etc.) und
|
||||||
|
baut dann das Backend und das Frontend. Das dauert beim ersten Mal 3-5 Minuten.
|
||||||
|
|
||||||
|
Danach läuft das System und startet automatisch nach einem Server-Reboot neu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 4 — Prüfen ob alles läuft
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Ausgabe sollte so aussehen:
|
||||||
|
|
||||||
|
```
|
||||||
|
NAME STATUS PORTS
|
||||||
|
rmm-db-1 Up (healthy) 5432/tcp
|
||||||
|
rmm-backend-1 Up 0.0.0.0:8443->8443/tcp
|
||||||
|
rmm-frontend-1 Up 0.0.0.0:80->80/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle drei Services müssen `Up` zeigen. Wenn `db` noch `starting` zeigt, warte
|
||||||
|
20-30 Sekunden — die Datenbank braucht beim allerersten Start etwas länger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 5 — Erster Login
|
||||||
|
|
||||||
|
Rufe das Frontend im Browser auf:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<BACKEND_HOST>
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start legt das Backend automatisch einen Admin-Account an.
|
||||||
|
Die Zugangsdaten stehen im Backend-Log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs backend | grep -i "admin\|password\|passwort"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Häufige Aufgaben
|
||||||
|
|
||||||
|
### Logs anschauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Services
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Nur Backend
|
||||||
|
docker compose logs -f backend
|
||||||
|
|
||||||
|
# Nur die letzten 50 Zeilen
|
||||||
|
docker compose logs --tail=50 backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### System stoppen und neu starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stoppen (Daten bleiben erhalten)
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Neu starten (z.B. nach Konfigurationsänderung)
|
||||||
|
docker compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### System aktualisieren
|
||||||
|
|
||||||
|
Nach einem `git pull` musst du die Images neu bauen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfiguration ändern (`.env` wurde bearbeitet)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nach Änderung von BACKEND_HOST, API_KEY etc.:
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose erkennt automatisch, dass sich die ENV-Werte geändert haben
|
||||||
|
und startet die betroffenen Container neu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datensicherung
|
||||||
|
|
||||||
|
Die Datenbank liegt in einem Docker Volume (`pgdata`). Um ein Backup zu erstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
docker compose exec db pg_dump -U rmm rmm > rmm-backup-$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Backup wiederherstellen
|
||||||
|
docker compose exec -T db psql -U rmm rmm < rmm-backup-20260101.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend startet nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Häufige Ursachen:
|
||||||
|
- **Datenbank noch nicht bereit**: Warte 30 Sekunden, dann `docker compose restart backend`
|
||||||
|
- **Port 8443 belegt**: Prüfe mit `ss -tlnp | grep 8443` was den Port belegt
|
||||||
|
|
||||||
|
### Frontend zeigt „Verbindung fehlgeschlagen"
|
||||||
|
|
||||||
|
- Prüfe ob `BACKEND_HOST` in `.env` die richtige IP-Adresse ist
|
||||||
|
- Prüfe ob Port 8443 in der Firewall freigegeben ist
|
||||||
|
- Teste direkt: `curl -sk https://<BACKEND_HOST>:8443/api/v1/health`
|
||||||
|
|
||||||
|
### Datenbank-Volume zurücksetzen (Achtung: löscht alle Daten)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v # -v löscht auch Volumes
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ports
|
||||||
|
|
||||||
|
| Port | Service | Beschreibung |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| 80 | Frontend | Web-Oberfläche (Browser) |
|
||||||
|
| 8443 | Backend | REST-API + WebSocket (Agents + Frontend) |
|
||||||
|
|
||||||
|
Port 80 und 8443 müssen von deinem Netzwerk erreichbar sein.
|
||||||
|
Port 5432 (Datenbank) ist nur intern verfügbar.
|
||||||
@ -51,7 +51,7 @@ Dann `config.js` anpassen:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// frontend/src/config.js
|
// frontend/src/config.js
|
||||||
export const BACKEND_HOST = '192.168.85.13' // IP des Backend-Servers (für direkte Links, z.B. Tunnel-Ports)
|
export const BACKEND_HOST = '<BACKEND-IP>' // IP des Backend-Servers (für direkte Links, z.B. Tunnel-Ports)
|
||||||
export const BACKEND_URL = `https://${BACKEND_HOST}:8443`
|
export const BACKEND_URL = `https://${BACKEND_HOST}:8443`
|
||||||
export const API_KEY = 'DEIN_API_KEY' // aus backend/config.yaml → api_keys[0]
|
export const API_KEY = 'DEIN_API_KEY' // aus backend/config.yaml → api_keys[0]
|
||||||
```
|
```
|
||||||
@ -66,7 +66,7 @@ Für die **lokale Entwicklung** (`npm run dev`) muss zusätzlich `vite.config.js
|
|||||||
// vite.config.js → server.proxy
|
// vite.config.js → server.proxy
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://192.168.85.13:8443', // Backend-IP anpassen
|
target: 'https://<BACKEND-IP>:8443', // Backend-IP anpassen
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true, // WebSocket-Proxy aktivieren (Web Terminal, Agent-WS)
|
ws: true, // WebSocket-Proxy aktivieren (Web Terminal, Agent-WS)
|
||||||
@ -127,7 +127,7 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
|
|
||||||
proxy_pass https://192.168.85.13:8443;
|
proxy_pass https://<BACKEND-IP>:8443;
|
||||||
proxy_ssl_verify off;
|
proxy_ssl_verify off;
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@ -166,7 +166,7 @@ rm -f /etc/nginx/sites-enabled/default
|
|||||||
nginx -t && systemctl reload nginx
|
nginx -t && systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Backend-IP anpassen:** `192.168.85.13:8443` durch die tatsächliche Backend-Adresse ersetzen.
|
> **Backend-IP anpassen:** `<BACKEND-IP>:8443` durch die tatsächliche Backend-Adresse ersetzen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -228,7 +228,7 @@ API-Calls werden per Proxy an das Backend weitergeleitet — `vite.config.js` an
|
|||||||
// vite.config.js
|
// vite.config.js
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://192.168.85.13:8443', // Backend-IP anpassen
|
target: 'https://<BACKEND-IP>:8443', // Backend-IP anpassen
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true, // WebSocket-Proxy (Web Terminal, Agent-WS)
|
ws: true, // WebSocket-Proxy (Web Terminal, Agent-WS)
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
# RMM Frontend Konfiguration
|
# RMM Frontend Konfiguration
|
||||||
# Datei kopieren: cp .env.example .env (wird nicht eingecheckt)
|
# Kopiere diese Datei nach .env und trage deine Werte ein
|
||||||
|
|
||||||
VITE_BACKEND_HOST=your-backend.example.com
|
# IP/Hostname des RMM Backends (ohne https://, ohne Port)
|
||||||
|
VITE_BACKEND_HOST=your-backend-host
|
||||||
|
|
||||||
|
# Port des RMM Backends
|
||||||
VITE_BACKEND_PORT=8443
|
VITE_BACKEND_PORT=8443
|
||||||
|
|
||||||
|
# API-Key fuer den Frontend-Zugriff (aus den Backend-Einstellungen)
|
||||||
VITE_API_KEY=your-api-key-here
|
VITE_API_KEY=your-api-key-here
|
||||||
|
|||||||
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Konfiguration via ENV zur Laufzeit (kein Rebuild bei IP-Aenderung noetig)
|
||||||
|
ENV BACKEND_HOST=localhost \
|
||||||
|
BACKEND_PORT=8443 \
|
||||||
|
API_KEY=""
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
@ -265,7 +265,7 @@ DELETE /api/v1/agents/{id}/wireguard/peers/{uuid} Peer loeschen
|
|||||||
# Peer anlegen (Auto: Keypair, naechste freie IP, Endpoint)
|
# Peer anlegen (Auto: Keypair, naechste freie IP, Endpoint)
|
||||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||||
https://your-backend:8443/api/v1/agents/6beb8dfd.../wireguard/peers \
|
https://your-backend:8443/api/v1/agents/6beb8dfd.../wireguard/peers \
|
||||||
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}'
|
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"<DNS-SERVER>"}'
|
||||||
# → {peer_config: "[Interface]\nPrivateKey=...\n[Peer]\n...", ...}
|
# → {peer_config: "[Interface]\nPrivateKey=...\n[Peer]\n...", ...}
|
||||||
|
|
||||||
# Alle Peers
|
# Alle Peers
|
||||||
|
|||||||
13
frontend/docker-entrypoint.sh
Normal file
13
frontend/docker-entrypoint.sh
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Generiert /usr/share/nginx/html/runtime-config.js aus ENV-Variablen
|
||||||
|
# Wird beim Container-Start ausgefuehrt
|
||||||
|
|
||||||
|
cat > /usr/share/nginx/html/runtime-config.js << EOF
|
||||||
|
window.__RMM_CONFIG__ = {
|
||||||
|
backendHost: "${BACKEND_HOST:-localhost}",
|
||||||
|
backendPort: "${BACKEND_PORT:-8443}",
|
||||||
|
apiKey: "${API_KEY:-}"
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
@ -1,6 +1,8 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<!-- Runtime-Konfiguration (Docker): wird von nginx aus ENV-Vars generiert -->
|
||||||
|
<script src="/runtime-config.js" onerror="window.__RMM_CONFIG__={}"></script>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
21
frontend/nginx.conf
Normal file
21
frontend/nginx.conf
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA-Routing: alle Pfade auf index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Caching fuer Assets
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kein Caching fuer index.html
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/public/runtime-config.js
Normal file
3
frontend/public/runtime-config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// Lokaler Entwicklungs-Fallback — in Docker wird diese Datei durch
|
||||||
|
// docker-entrypoint.sh mit echten Werten ueberschrieben
|
||||||
|
window.__RMM_CONFIG__ = {};
|
||||||
@ -5,7 +5,6 @@ import AppLayout from './layouts/AppLayout'
|
|||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Agents from './pages/Agents'
|
import Agents from './pages/Agents'
|
||||||
import Windows from './pages/Windows'
|
|
||||||
import AgentDetail from './pages/AgentDetail'
|
import AgentDetail from './pages/AgentDetail'
|
||||||
import ProxmoxServers from './pages/ProxmoxServers'
|
import ProxmoxServers from './pages/ProxmoxServers'
|
||||||
import Customers from './pages/Customers'
|
import Customers from './pages/Customers'
|
||||||
@ -54,7 +53,6 @@ export default function App() {
|
|||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="agents" element={<Agents />} />
|
<Route path="agents" element={<Agents />} />
|
||||||
<Route path="agents/:id" element={<AgentDetail />} />
|
<Route path="agents/:id" element={<AgentDetail />} />
|
||||||
<Route path="windows" element={<Windows />} />
|
|
||||||
<Route path="proxmox" element={<ProxmoxServers />} />
|
<Route path="proxmox" element={<ProxmoxServers />} />
|
||||||
<Route path="tunnels" element={<Tunnels />} />
|
<Route path="tunnels" element={<Tunnels />} />
|
||||||
<Route path="customers" element={<Customers />} />
|
<Route path="customers" element={<Customers />} />
|
||||||
|
|||||||
@ -224,10 +224,6 @@ class ApiClient {
|
|||||||
return this.post(`/api/v1/agents/${agentId}/exec`, { command, timeout })
|
return this.post(`/api/v1/agents/${agentId}/exec`, { command, timeout })
|
||||||
}
|
}
|
||||||
|
|
||||||
proxmoxAction(agentId, type, vmid, action) {
|
|
||||||
return this.post(`/api/v1/agents/${agentId}/proxmox/action`, { type, vmid, action })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
getUsers() {
|
getUsers() {
|
||||||
return this.get('/api/v1/users')
|
return this.get('/api/v1/users')
|
||||||
@ -250,45 +246,12 @@ class ApiClient {
|
|||||||
return this.get('/api/v1/apikeys')
|
return this.get('/api/v1/apikeys')
|
||||||
}
|
}
|
||||||
|
|
||||||
getCustomerAPIKeys(customerId) {
|
createAPIKey(name) {
|
||||||
return this.get(`/api/v1/customers/${customerId}/apikeys`)
|
return this.post('/api/v1/apikeys', { name })
|
||||||
}
|
|
||||||
|
|
||||||
// WAU
|
|
||||||
getWAURules(customerId) {
|
|
||||||
return this.get(`/api/v1/customers/${customerId}/wau/rules`)
|
|
||||||
}
|
|
||||||
|
|
||||||
addWAURule(customerId, wingetId, displayName, ruleType) {
|
|
||||||
return this.post(`/api/v1/customers/${customerId}/wau/rules`, {
|
|
||||||
winget_id: wingetId,
|
|
||||||
display_name: displayName,
|
|
||||||
rule_type: ruleType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteWAURule(customerId, ruleId) {
|
|
||||||
return this.del(`/api/v1/customers/${customerId}/wau/rules/${ruleId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
getWAUInventory(customerId) {
|
|
||||||
return this.get(`/api/v1/customers/${customerId}/wau/inventory`)
|
|
||||||
}
|
|
||||||
|
|
||||||
getWAUCompliance(customerId) {
|
|
||||||
return this.get(`/api/v1/customers/${customerId}/wau/compliance`)
|
|
||||||
}
|
|
||||||
|
|
||||||
getWAUComplianceSummary() {
|
|
||||||
return this.get('/api/v1/wau/compliance/summary')
|
|
||||||
}
|
|
||||||
|
|
||||||
createAPIKey(name, permissions = 'read', customerId = null) {
|
|
||||||
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAPIKey(id) {
|
deleteAPIKey(id) {
|
||||||
return this.del(`/api/v1/apikeys/${id}`)
|
return this.delete(`/api/v1/apikeys/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// System Settings
|
// System Settings
|
||||||
|
|||||||
@ -1178,7 +1178,7 @@ function WireGuardTab({ agentId, sys }) {
|
|||||||
const [peers, setPeers] = useState([])
|
const [peers, setPeers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [newPeer, setNewPeer] = useState({ name: '', dns: '10.172.100.210, 10.172.100.220' })
|
const [newPeer, setNewPeer] = useState({ name: '', dns: '' })
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@ -1213,7 +1213,7 @@ function WireGuardTab({ agentId, sys }) {
|
|||||||
})
|
})
|
||||||
setResult(resp?.data || resp)
|
setResult(resp?.data || resp)
|
||||||
setAdding(false)
|
setAdding(false)
|
||||||
setNewPeer({ name: '', dns: '10.172.100.210, 10.172.100.220' })
|
setNewPeer({ name: '', dns: '' })
|
||||||
loadPeers()
|
loadPeers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|||||||
@ -9,54 +9,34 @@ import {
|
|||||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
||||||
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
|
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
|
||||||
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, FileCode,
|
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, FileCode,
|
||||||
LayoutGrid, Archive, GitBranch, Wrench, RefreshCw, CalendarClock, Bot,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const mainCategories = [
|
const buildTabs = (hasPBS) => [
|
||||||
{ id: 'uebersicht', label: 'Uebersicht' },
|
{ id: 'overview', label: 'Uebersicht' },
|
||||||
{ id: 'virtualisierung', label: 'Virtualisierung' },
|
{ id: 'vms', label: 'Virtuelle Maschinen' },
|
||||||
{ id: 'speicher', label: 'Speicher' },
|
{ id: 'containers', label: 'Container' },
|
||||||
{ id: 'system', label: 'System' },
|
{ id: 'storage', label: 'Storage' },
|
||||||
|
{ id: 'zfs', label: 'ZFS' },
|
||||||
|
...(hasPBS ? [{ id: 'pbs', label: 'Backup Server' }] : []),
|
||||||
|
{ id: 'tunnel', label: 'Tunnel' },
|
||||||
|
{ id: 'services', label: 'Dienste' },
|
||||||
|
{ id: 'updates', label: 'Updates' },
|
||||||
|
{ id: 'tasks', label: 'Aufgaben' },
|
||||||
|
{ id: 'backups', label: 'Backups' },
|
||||||
|
{ id: 'agent', label: 'Agent' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const buildSubTabs = (hasPBS) => ({
|
|
||||||
virtualisierung: [
|
|
||||||
{ id: 'vms', label: 'VMs', icon: Monitor },
|
|
||||||
{ id: 'containers', label: 'Container', icon: Container },
|
|
||||||
],
|
|
||||||
speicher: [
|
|
||||||
{ id: 'storage', label: 'Storage', icon: HardDrive },
|
|
||||||
{ id: 'zfs', label: 'ZFS', icon: Database },
|
|
||||||
{ id: 'backups', label: 'Backups', icon: Archive },
|
|
||||||
...(hasPBS ? [{ id: 'pbs', label: 'Backup Server', icon: Server }] : []),
|
|
||||||
],
|
|
||||||
system: [
|
|
||||||
{ id: 'services', label: 'Dienste', icon: Settings },
|
|
||||||
{ id: 'updates', label: 'Updates', icon: RefreshCw },
|
|
||||||
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock },
|
|
||||||
{ id: 'tunnel', label: 'Tunnel', icon: Cable },
|
|
||||||
{ id: 'agent', label: 'Agent', icon: Bot },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) {
|
export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) {
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [mainTab, setMainTab] = useState('uebersicht')
|
const [tab, setTab] = useState('overview')
|
||||||
const [subTab, setSubTab] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setMainTab('uebersicht')
|
setTab('overview')
|
||||||
setSubTab(null)
|
|
||||||
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
|
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
|
||||||
}, [agentId])
|
}, [agentId])
|
||||||
|
|
||||||
const handleMainTab = (id, subTabs) => {
|
|
||||||
setMainTab(id)
|
|
||||||
setSubTab(subTabs?.[id]?.[0]?.id ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
const iv = setInterval(() => {
|
const iv = setInterval(() => {
|
||||||
@ -72,32 +52,12 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
|
|||||||
const proxmox = sys?.proxmox
|
const proxmox = sys?.proxmox
|
||||||
const pbs = sys?.pbs
|
const pbs = sys?.pbs
|
||||||
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
||||||
const subs = buildSubTabs(pbs?.available)
|
const tabs = buildTabs(pbs?.available)
|
||||||
const currentSubs = mainTab !== 'uebersicht' ? (subs[mainTab] || []) : []
|
|
||||||
const activeSubTab = currentSubs.find(s => s.id === subTab)?.id ?? currentSubs[0]?.id
|
|
||||||
|
|
||||||
if (!proxmox?.available) {
|
if (!proxmox?.available) {
|
||||||
return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel>
|
return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel>
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
|
|
||||||
if (mainTab === 'uebersicht') return <OverviewTab sys={sys} proxmox={proxmox} />
|
|
||||||
const t = activeSubTab
|
|
||||||
if (t === 'vms') return <VmsTab proxmox={proxmox} agentId={agentId} />
|
|
||||||
if (t === 'containers') return <ContainersTab proxmox={proxmox} agentId={agentId} />
|
|
||||||
if (t === 'storage') return <StorageTab proxmox={proxmox} />
|
|
||||||
if (t === 'zfs') return <ZfsTab proxmox={proxmox} />
|
|
||||||
if (t === 'backups') return <BackupsTab sys={sys} />
|
|
||||||
if (t === 'pbs') return <PBSTab pbs={pbs} agentId={agentId} sys={sys} />
|
|
||||||
if (t === 'services') return <ServicesTab sys={sys} />
|
|
||||||
if (t === 'updates') return <UpdatesTab sys={sys} />
|
|
||||||
if (t === 'tasks') return <LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
|
|
||||||
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
|
|
||||||
if (t === 'agent') return <AgentTab agent={data?.agent} status={data?.status} />
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel onClose={onClose}>
|
<Panel onClose={onClose}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -132,60 +92,53 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Haupt-Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
||||||
{mainCategories.map((cat) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={cat.id}
|
key={t.id}
|
||||||
onClick={() => handleMainTab(cat.id, subs)}
|
onClick={() => setTab(t.id)}
|
||||||
className={`px-4 py-1.5 whitespace-nowrap transition-colors border-b-2 ${
|
className={`px-3 py-1.5 rounded-t whitespace-nowrap transition-colors ${
|
||||||
mainTab === cat.id
|
tab === t.id
|
||||||
? 'text-orange-400 border-orange-400'
|
? 'bg-gray-900 text-orange-400 border-t border-x border-gray-700'
|
||||||
: 'text-gray-400 hover:text-gray-200 border-transparent'
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cat.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content + optionale Sidebar */}
|
{/* Content */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{!sys ? (
|
||||||
{/* Sub-Navigation links — nur wenn nicht Uebersicht */}
|
<div className="text-gray-500">Keine Systemdaten</div>
|
||||||
{mainTab !== 'uebersicht' && currentSubs.length > 0 && (
|
) : tab === 'overview' ? (
|
||||||
<div className="w-44 shrink-0 bg-gray-900/50 border-r border-gray-800 overflow-y-auto py-3">
|
<OverviewTab sys={sys} proxmox={proxmox} />
|
||||||
<div className="px-4 mb-2">
|
) : tab === 'vms' ? (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
|
<VmsTab proxmox={proxmox} />
|
||||||
{mainCategories.find(c => c.id === mainTab)?.label}
|
) : tab === 'containers' ? (
|
||||||
</span>
|
<ContainersTab proxmox={proxmox} />
|
||||||
</div>
|
) : tab === 'storage' ? (
|
||||||
{currentSubs.map((item) => {
|
<StorageTab proxmox={proxmox} />
|
||||||
const Icon = item.icon
|
) : tab === 'zfs' ? (
|
||||||
const isActive = activeSubTab === item.id
|
<ZfsTab proxmox={proxmox} />
|
||||||
return (
|
) : tab === 'tunnel' ? (
|
||||||
<button
|
<TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
|
||||||
key={item.id}
|
) : tab === 'services' ? (
|
||||||
onClick={() => setSubTab(item.id)}
|
<ServicesTab sys={sys} />
|
||||||
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors ${
|
) : tab === 'updates' ? (
|
||||||
isActive
|
<UpdatesTab sys={sys} />
|
||||||
? 'text-orange-400 bg-orange-400/5 border-l-2 border-orange-400'
|
) : tab === 'tasks' ? (
|
||||||
: 'text-gray-400 hover:text-white border-l-2 border-transparent'
|
<LinuxTasksTab agentId={agentId} agentName={data?.agent?.name || ''} />
|
||||||
}`}
|
) : tab === 'pbs' ? (
|
||||||
>
|
<PBSTab pbs={pbs} agentId={agentId} sys={sys} />
|
||||||
{Icon && <Icon className="w-4 h-4 shrink-0" />}
|
) : tab === 'backups' ? (
|
||||||
<span>{item.label}</span>
|
<BackupsTab sys={sys} />
|
||||||
</button>
|
) : tab === 'agent' ? (
|
||||||
)
|
<AgentTab agent={data?.agent} status={data?.status} />
|
||||||
})}
|
) : null}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hauptinhalt */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
)
|
)
|
||||||
@ -664,76 +617,16 @@ function OverviewTab({ sys, proxmox }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VmActionButtons({ status, loading, onStart, onStop, onShutdown }) {
|
function VmsTab({ proxmox }) {
|
||||||
const isRunning = status === 'running'
|
|
||||||
const isStopped = status === 'stopped'
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <span className="text-xs text-gray-500 animate-pulse">...</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{isStopped && (
|
|
||||||
<button
|
|
||||||
onClick={onStart}
|
|
||||||
title="Starten"
|
|
||||||
className="p-1 rounded text-green-400 hover:bg-green-900/40 hover:text-green-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.344-5.891a1.5 1.5 0 000-2.538L6.3 2.84z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isRunning && onShutdown && (
|
|
||||||
<button
|
|
||||||
onClick={onShutdown}
|
|
||||||
title="Graceful Shutdown"
|
|
||||||
className="p-1 rounded text-yellow-400 hover:bg-yellow-900/40 hover:text-yellow-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isRunning && (
|
|
||||||
<button
|
|
||||||
onClick={onStop}
|
|
||||||
title="Hard Stop"
|
|
||||||
className="p-1 rounded text-red-400 hover:bg-red-900/40 hover:text-red-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M4 4h12v12H4V4z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VmsTab({ proxmox, agentId }) {
|
|
||||||
const vms = proxmox.vms || []
|
const vms = proxmox.vms || []
|
||||||
const [pending, setPending] = useState({}) // vmid -> true wenn Action läuft
|
|
||||||
const [errors, setErrors] = useState({})
|
// Sort: running first, then by name
|
||||||
|
|
||||||
const sortedVms = [...vms].sort((a, b) => {
|
const sortedVms = [...vms].sort((a, b) => {
|
||||||
if (a.status === 'running' && b.status !== 'running') return -1
|
if (a.status === 'running' && b.status !== 'running') return -1
|
||||||
if (a.status !== 'running' && b.status === 'running') return 1
|
if (a.status !== 'running' && b.status === 'running') return 1
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const doAction = async (vmid, action) => {
|
|
||||||
setPending(p => ({ ...p, [vmid]: true }))
|
|
||||||
setErrors(e => ({ ...e, [vmid]: null }))
|
|
||||||
try {
|
|
||||||
await api.proxmoxAction(agentId, 'vm', vmid, action)
|
|
||||||
} catch (err) {
|
|
||||||
setErrors(e => ({ ...e, [vmid]: err.message }))
|
|
||||||
} finally {
|
|
||||||
setPending(p => ({ ...p, [vmid]: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
|
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
|
||||||
@ -751,7 +644,6 @@ function VmsTab({ proxmox, agentId }) {
|
|||||||
<th className="px-3 py-2">RAM</th>
|
<th className="px-3 py-2">RAM</th>
|
||||||
<th className="px-3 py-2">Disk</th>
|
<th className="px-3 py-2">Disk</th>
|
||||||
<th className="px-3 py-2">Uptime</th>
|
<th className="px-3 py-2">Uptime</th>
|
||||||
<th className="px-3 py-2"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
@ -761,31 +653,21 @@ function VmsTab({ proxmox, agentId }) {
|
|||||||
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<StatusBadge status={vm.status} />
|
<StatusBadge status={vm.status} />
|
||||||
{errors[vm.vmid] && <div className="text-red-400 text-xs mt-1">{errors[vm.vmid]}</div>}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{vm.memory_used && vm.memory_max ?
|
{vm.memory_used && vm.memory_max ?
|
||||||
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
|
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{vm.disk_used != null && vm.disk_max ?
|
{vm.disk_used != null && vm.disk_max ?
|
||||||
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
|
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
|
||||||
<VmActionButtons
|
|
||||||
status={vm.status}
|
|
||||||
loading={!!pending[vm.vmid]}
|
|
||||||
onStart={() => doAction(vm.vmid, 'start')}
|
|
||||||
onStop={() => doAction(vm.vmid, 'stop')}
|
|
||||||
onShutdown={() => doAction(vm.vmid, 'shutdown')}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -796,29 +678,16 @@ function VmsTab({ proxmox, agentId }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainersTab({ proxmox, agentId }) {
|
function ContainersTab({ proxmox }) {
|
||||||
const containers = proxmox.containers || []
|
const containers = proxmox.containers || []
|
||||||
const [pending, setPending] = useState({})
|
|
||||||
const [errors, setErrors] = useState({})
|
// Sort: running first, then by name
|
||||||
|
|
||||||
const sortedCt = [...containers].sort((a, b) => {
|
const sortedCt = [...containers].sort((a, b) => {
|
||||||
if (a.status === 'running' && b.status !== 'running') return -1
|
if (a.status === 'running' && b.status !== 'running') return -1
|
||||||
if (a.status !== 'running' && b.status === 'running') return 1
|
if (a.status !== 'running' && b.status === 'running') return 1
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const doAction = async (vmid, action) => {
|
|
||||||
setPending(p => ({ ...p, [vmid]: true }))
|
|
||||||
setErrors(e => ({ ...e, [vmid]: null }))
|
|
||||||
try {
|
|
||||||
await api.proxmoxAction(agentId, 'ct', vmid, action)
|
|
||||||
} catch (err) {
|
|
||||||
setErrors(e => ({ ...e, [vmid]: err.message }))
|
|
||||||
} finally {
|
|
||||||
setPending(p => ({ ...p, [vmid]: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
|
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
|
||||||
@ -835,7 +704,6 @@ function ContainersTab({ proxmox, agentId }) {
|
|||||||
<th className="px-3 py-2">CPU</th>
|
<th className="px-3 py-2">CPU</th>
|
||||||
<th className="px-3 py-2">RAM</th>
|
<th className="px-3 py-2">RAM</th>
|
||||||
<th className="px-3 py-2">Uptime</th>
|
<th className="px-3 py-2">Uptime</th>
|
||||||
<th className="px-3 py-2"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
@ -845,26 +713,17 @@ function ContainersTab({ proxmox, agentId }) {
|
|||||||
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<StatusBadge status={ct.status} />
|
<StatusBadge status={ct.status} />
|
||||||
{errors[ct.vmid] && <div className="text-red-400 text-xs mt-1">{errors[ct.vmid]}</div>}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{ct.memory_used && ct.memory_max ?
|
{ct.memory_used && ct.memory_max ?
|
||||||
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
|
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
|
||||||
<VmActionButtons
|
|
||||||
status={ct.status}
|
|
||||||
loading={!!pending[ct.vmid]}
|
|
||||||
onStart={() => doAction(ct.vmid, 'start')}
|
|
||||||
onStop={() => doAction(ct.vmid, 'stop')}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { X, Play, Loader, Terminal, Copy, Check } from 'lucide-react'
|
import { X, Play, Loader, Terminal, Copy, Check } from 'lucide-react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
|
||||||
|
|
||||||
export default function ScriptModal({ agentId, agentName, onClose }) {
|
export default function ScriptModal({ agentId, agentName, onClose }) {
|
||||||
const [script, setScript] = useState('#!/bin/bash\n\n')
|
const [script, setScript] = useState('#!/bin/bash\n\n')
|
||||||
@ -36,7 +35,7 @@ export default function ScriptModal({ agentId, agentName, onClose }) {
|
|||||||
|
|
||||||
const copyOutput = () => {
|
const copyOutput = () => {
|
||||||
if (output) {
|
if (output) {
|
||||||
copyToClipboard(output)
|
navigator.clipboard.writeText(output)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { FitAddon } from '@xterm/addon-fit'
|
|||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { API_KEY } from '../config'
|
import { API_KEY } from '../config'
|
||||||
import api from '../api/client'
|
|
||||||
|
|
||||||
export default function TerminalModal({ agentId, agentName, onClose }) {
|
export default function TerminalModal({ agentId, agentName, onClose }) {
|
||||||
const termRef = useRef(null)
|
const termRef = useRef(null)
|
||||||
@ -14,8 +13,6 @@ export default function TerminalModal({ agentId, agentName, onClose }) {
|
|||||||
const [status, setStatus] = useState('connecting') // connecting | open | error | closed
|
const [status, setStatus] = useState('connecting') // connecting | open | error | closed
|
||||||
|
|
||||||
const backendHost = useSettingsStore.getState().getBackendHost()
|
const backendHost = useSettingsStore.getState().getBackendHost()
|
||||||
// JWT-Token bevorzugen (Browser ist per Login authentifiziert), API-Key als Fallback
|
|
||||||
const jwtToken = api.getToken()
|
|
||||||
const apiKey = API_KEY
|
const apiKey = API_KEY
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -61,13 +58,7 @@ export default function TerminalModal({ agentId, agentName, onClose }) {
|
|||||||
const cols = term.cols || 220
|
const cols = term.cols || 220
|
||||||
const rows = term.rows || 50
|
const rows = term.rows || 50
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
// JWT bevorzugen, API-Key als Fallback (API-Key nur wenn konfiguriert)
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/v1/agents/${agentId}/terminal?cols=${cols}&rows=${rows}&api_key=${apiKey}`
|
||||||
const authParam = jwtToken
|
|
||||||
? `token=${encodeURIComponent(jwtToken)}`
|
|
||||||
: apiKey
|
|
||||||
? `api_key=${encodeURIComponent(apiKey)}`
|
|
||||||
: ''
|
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/v1/agents/${agentId}/terminal?cols=${cols}&rows=${rows}&${authParam}`
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
ws.binaryType = 'arraybuffer'
|
ws.binaryType = 'arraybuffer'
|
||||||
|
|||||||
@ -1,903 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import api from '../api/client'
|
|
||||||
import { useSettingsStore } from '../stores/settings'
|
|
||||||
import StatusBadge from './StatusBadge'
|
|
||||||
import TerminalModal from './TerminalModal'
|
|
||||||
import ScriptModal from './ScriptModal'
|
|
||||||
import {
|
|
||||||
X, Cpu, MemoryStick, HardDrive, Monitor, Server, Settings,
|
|
||||||
Download, Trash2, RefreshCw, Package, Search, Play,
|
|
||||||
ShieldCheck, Bot, Cable, CalendarClock, Zap, CheckCircle2, AlertCircle, Clock,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const mainCategories = [
|
|
||||||
{ id: 'uebersicht', label: 'Uebersicht' },
|
|
||||||
{ id: 'software', label: 'Software' },
|
|
||||||
{ id: 'system', label: 'System' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const subTabs = {
|
|
||||||
software: [
|
|
||||||
{ id: 'installed', label: 'Installiert', icon: Package },
|
|
||||||
{ id: 'winget', label: 'winget Updates', icon: RefreshCw },
|
|
||||||
{ id: 'wupdates', label: 'Windows Updates', icon: ShieldCheck },
|
|
||||||
{ id: 'wau', label: 'WAU', icon: Zap },
|
|
||||||
],
|
|
||||||
system: [
|
|
||||||
{ id: 'services', label: 'Dienste', icon: Settings },
|
|
||||||
{ id: 'tunnel', label: 'Tunnel', icon: Cable },
|
|
||||||
{ id: 'tasks', label: 'Aufgaben', icon: CalendarClock },
|
|
||||||
{ id: 'agent', label: 'Agent', icon: Bot },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Panel Shell ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Panel({ onClose, children }) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-stretch justify-end bg-black/50">
|
|
||||||
<div className="w-full max-w-5xl bg-gray-900 flex flex-col shadow-2xl border-l border-gray-700 overflow-hidden">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CustomerAssign ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CustomerAssign({ agent, customers, current, onReload }) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const handleChange = async (e) => {
|
|
||||||
const val = e.target.value
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
if (val === '') await api.unassignCustomer(agent.id)
|
|
||||||
else await api.assignCustomer(agent.id, parseInt(val))
|
|
||||||
if (onReload) onReload()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editing) return (
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<select
|
|
||||||
value={agent.customer_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={saving}
|
|
||||||
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-sky-500"
|
|
||||||
autoFocus
|
|
||||||
onBlur={() => !saving && setEditing(false)}
|
|
||||||
>
|
|
||||||
<option value="">— Nicht zugewiesen —</option>
|
|
||||||
{customers.sort((a, b) => a.number.localeCompare(b.number)).map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{saving && <span className="text-xs text-gray-500">Speichere...</span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="text-sm mt-0.5 cursor-pointer hover:underline"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
title="Kunde aendern"
|
|
||||||
>
|
|
||||||
{current
|
|
||||||
? <span className="text-sky-400">{current.number} — {current.name}</span>
|
|
||||||
: <span className="text-gray-500 italic">Kein Kunde zugewiesen (klicken zum Zuweisen)</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Component ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function WindowsPanel({ agentId, customers, onClose, onReload }) {
|
|
||||||
const [data, setData] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [mainTab, setMainTab] = useState('uebersicht')
|
|
||||||
const [subTab, setSubTab] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true)
|
|
||||||
setMainTab('uebersicht')
|
|
||||||
setSubTab(null)
|
|
||||||
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
|
|
||||||
}, [agentId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data) return
|
|
||||||
const iv = setInterval(() => api.getAgent(agentId).then(setData), 30000)
|
|
||||||
return () => clearInterval(iv)
|
|
||||||
}, [agentId, data])
|
|
||||||
|
|
||||||
if (loading) return <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
|
|
||||||
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
|
|
||||||
|
|
||||||
const { agent, system_data: sys, status } = data
|
|
||||||
const win = sys?.windows
|
|
||||||
const cust = agent.customer_id ? customers.find(c => c.id === agent.customer_id) : null
|
|
||||||
const currentSubs = mainTab !== 'uebersicht' ? (subTabs[mainTab] || []) : []
|
|
||||||
const activeSubTab = currentSubs.find(s => s.id === subTab)?.id ?? currentSubs[0]?.id
|
|
||||||
|
|
||||||
const handleMainTab = (id) => {
|
|
||||||
setMainTab(id)
|
|
||||||
setSubTab(subTabs[id]?.[0]?.id ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (mainTab === 'uebersicht') return <OverviewTab win={win} agent={agent} status={status} />
|
|
||||||
const t = activeSubTab
|
|
||||||
if (t === 'installed') return <InstalledSoftwareTab win={win} />
|
|
||||||
if (t === 'winget') return <WingetUpgradeTab agentId={agentId} />
|
|
||||||
if (t === 'wupdates') return <WindowsUpdatesTab agentId={agentId} />
|
|
||||||
if (t === 'wau') return <WAUTab agentId={agentId} customerId={agent.customer_id} />
|
|
||||||
if (t === 'services') return <ServicesTab win={win} />
|
|
||||||
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={agent} />
|
|
||||||
if (t === 'tasks') return <TasksTab agentId={agentId} agentName={agent.name} />
|
|
||||||
if (t === 'agent') return <AgentInfoTab agent={agent} status={status} />
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel onClose={onClose}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-6 py-4 bg-gradient-to-r from-gray-800 to-gray-900 border-b border-gray-700">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Monitor className="w-5 h-5 text-blue-400" />
|
|
||||||
<h2 className="text-lg font-bold text-white">{agent.name}</h2>
|
|
||||||
<StatusBadge status={status} />
|
|
||||||
</div>
|
|
||||||
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
{win?.hostname || agent.hostname}
|
|
||||||
{win?.os_version && <span> · {win.os_version}</span>}
|
|
||||||
{win?.domain && <span> · {win.domain}</span>}
|
|
||||||
<span> · IP: {agent.ip}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm(`Agent "${agent.name}" wirklich loeschen?`)) return
|
|
||||||
await api.deleteAgent(agent.id)
|
|
||||||
if (onReload) onReload()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
|
||||||
title="Agent loeschen"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Haupt-Tabs */}
|
|
||||||
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
|
||||||
{mainCategories.map(cat => (
|
|
||||||
<button key={cat.id} onClick={() => handleMainTab(cat.id)}
|
|
||||||
className={`px-4 py-1.5 whitespace-nowrap transition-colors border-b-2 ${
|
|
||||||
mainTab === cat.id
|
|
||||||
? 'text-blue-400 border-blue-400'
|
|
||||||
: 'text-gray-400 hover:text-gray-200 border-transparent'
|
|
||||||
}`}>
|
|
||||||
{cat.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content + Sidebar */}
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
|
||||||
{/* Sub-Navigation */}
|
|
||||||
{mainTab !== 'uebersicht' && currentSubs.length > 0 && (
|
|
||||||
<div className="w-44 shrink-0 bg-gray-900/50 border-r border-gray-800 overflow-y-auto py-3">
|
|
||||||
<div className="px-4 mb-2">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
|
|
||||||
{mainCategories.find(c => c.id === mainTab)?.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{currentSubs.map(item => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const isActive = activeSubTab === item.id
|
|
||||||
return (
|
|
||||||
<button key={item.id} onClick={() => setSubTab(item.id)}
|
|
||||||
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'text-blue-400 bg-blue-400/5 border-l-2 border-blue-400'
|
|
||||||
: 'text-gray-400 hover:text-white border-l-2 border-transparent'
|
|
||||||
}`}>
|
|
||||||
{Icon && <Icon className="w-4 h-4 shrink-0" />}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inhalt */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Uebersicht ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function OverviewTab({ win, agent, status }) {
|
|
||||||
const fmt = (b) => {
|
|
||||||
if (!b) return '—'
|
|
||||||
if (b >= 1073741824) return `${(b / 1073741824).toFixed(1)} GB`
|
|
||||||
return `${(b / 1048576).toFixed(0)} MB`
|
|
||||||
}
|
|
||||||
const fmtUptime = (s) => {
|
|
||||||
if (!s) return '—'
|
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
|
||||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!win) return <div className="text-gray-500">Keine Systemdaten — Agent noch nicht verbunden oder erster Heartbeat ausstehend</div>
|
|
||||||
|
|
||||||
const memUsed = win.memory?.total_bytes - win.memory?.available_bytes
|
|
||||||
const memPct = win.memory?.used_percent ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Stats-Karten */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<StatCard icon={Cpu} label="CPU-Last" value={`${win.cpu?.load_percent?.toFixed(1) ?? '—'}%`} sub={win.cpu?.model?.split(' ').slice(-2).join(' ')} color="text-blue-400" pct={win.cpu?.load_percent} />
|
|
||||||
<StatCard icon={MemoryStick} label="RAM" value={`${memPct.toFixed(0)}%`} sub={`${fmt(memUsed)} / ${fmt(win.memory?.total_bytes)}`} color="text-purple-400" pct={memPct} />
|
|
||||||
<StatCard icon={Server} label="Uptime" value={fmtUptime(win.uptime_seconds)} sub="seit letztem Start" color="text-green-400" />
|
|
||||||
<StatCard icon={Monitor} label="OS" value={win.os_version?.replace('Windows ', 'Win ') || '—'} sub={win.os_build ? `Build ${win.os_build}` : ''} color="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CPU-Details */}
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
||||||
<div className="text-xs text-gray-500 mb-2 font-semibold uppercase tracking-wide">Prozessor</div>
|
|
||||||
<div className="text-sm text-white mb-2">{win.cpu?.model || '—'}</div>
|
|
||||||
<div className="flex gap-6 text-xs text-gray-400">
|
|
||||||
<span>{win.cpu?.cores ?? '—'} Kerne</span>
|
|
||||||
<span>{win.cpu?.logical_cores ?? '—'} logische Kerne</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<UsageBar pct={win.cpu?.load_percent ?? 0} color="bg-blue-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arbeitsspeicher */}
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
||||||
<div className="text-xs text-gray-500 mb-2 font-semibold uppercase tracking-wide">Arbeitsspeicher</div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-gray-300">Belegt: {fmt(memUsed)}</span>
|
|
||||||
<span className="text-gray-500">Gesamt: {fmt(win.memory?.total_bytes)}</span>
|
|
||||||
</div>
|
|
||||||
<UsageBar pct={memPct} color={memPct > 85 ? 'bg-red-500' : memPct > 65 ? 'bg-yellow-500' : 'bg-purple-500'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Festplatten */}
|
|
||||||
{win.disks?.length > 0 && (
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
||||||
<div className="text-xs text-gray-500 mb-3 font-semibold uppercase tracking-wide">Laufwerke</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{win.disks.map(d => (
|
|
||||||
<div key={d.drive}>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-white font-mono">{d.drive}</span>
|
|
||||||
<span className="text-gray-400 text-xs">{fmt(d.total_bytes - d.free_bytes)} / {fmt(d.total_bytes)} ({d.used_percent?.toFixed(0)}%)</span>
|
|
||||||
</div>
|
|
||||||
<UsageBar pct={d.used_percent} color={d.used_percent > 90 ? 'bg-red-500' : d.used_percent > 75 ? 'bg-yellow-500' : 'bg-green-600'} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Software / installiert ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function InstalledSoftwareTab({ win }) {
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const software = win?.installed_software || []
|
|
||||||
|
|
||||||
const filtered = search
|
|
||||||
? software.filter(s =>
|
|
||||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
(s.publisher || '').toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
: software
|
|
||||||
|
|
||||||
if (software.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-gray-500 text-sm">
|
|
||||||
Keine Software-Daten verfügbar — Agent läuft möglicherweise noch auf einer alten Version.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Name oder Hersteller suchen..."
|
|
||||||
className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500 shrink-0">{filtered.length} / {software.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-950 rounded border border-gray-800 overflow-hidden">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800 text-left text-gray-500">
|
|
||||||
<th className="px-3 py-2 font-medium">Name</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Version</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Hersteller</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Installiert</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-900">
|
|
||||||
{filtered.map((s, i) => (
|
|
||||||
<tr key={i} className="hover:bg-gray-900/60">
|
|
||||||
<td className="px-3 py-1.5 text-white">{s.name}</td>
|
|
||||||
<td className="px-3 py-1.5 text-gray-400 font-mono">{s.version || '—'}</td>
|
|
||||||
<td className="px-3 py-1.5 text-gray-500">{s.publisher || '—'}</td>
|
|
||||||
<td className="px-3 py-1.5 text-gray-600">{formatInstallDate(s.install_date)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className="text-center py-6 text-gray-500">Keine Treffer</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInstallDate(d) {
|
|
||||||
if (!d || d.length < 8) return '—'
|
|
||||||
// Format: YYYYMMDD
|
|
||||||
return `${d.slice(6, 8)}.${d.slice(4, 6)}.${d.slice(0, 4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function WingetUpgradeTab({ agentId }) {
|
|
||||||
const [output, setOutput] = useState(null)
|
|
||||||
const [running, setRunning] = useState(false)
|
|
||||||
const [upgradeAll, setUpgradeAll] = useState(false)
|
|
||||||
|
|
||||||
const check = async () => {
|
|
||||||
setRunning(true)
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId, 'winget upgrade --accept-source-agreements', 60)
|
|
||||||
setOutput(res?.output || res?.data?.output || '')
|
|
||||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
|
||||||
finally { setRunning(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const doUpgradeAll = async () => {
|
|
||||||
if (!confirm('Alle verfügbaren winget-Updates installieren?')) return
|
|
||||||
setUpgradeAll(true)
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId,
|
|
||||||
'winget upgrade --all --silent --accept-package-agreements --accept-source-agreements', 600)
|
|
||||||
setOutput(res?.output || res?.data?.output || '')
|
|
||||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
|
||||||
finally { setUpgradeAll(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { check() }, [agentId])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={check} disabled={running}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${running ? 'animate-spin' : ''}`} />
|
|
||||||
Pruefen
|
|
||||||
</button>
|
|
||||||
<button onClick={doUpgradeAll} disabled={running || upgradeAll}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
{upgradeAll ? 'Installiere...' : 'Alle updaten'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{(running || upgradeAll) && <div className="text-gray-400 text-sm">Bitte warten...</div>}
|
|
||||||
{output && (
|
|
||||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
|
|
||||||
{output}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WindowsUpdatesTab({ agentId }) {
|
|
||||||
const [output, setOutput] = useState(null)
|
|
||||||
const [checking, setChecking] = useState(false)
|
|
||||||
const [installing, setInstalling] = useState(false)
|
|
||||||
|
|
||||||
const script = `$s = New-Object -ComObject Microsoft.Update.Session; $r = $s.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software'"); $r.Updates | ForEach-Object { $kb = if ($_.KBArticleIDs.Count -gt 0) { "KB$($_.KBArticleIDs[0])" } else { "---" }; Write-Output "$kb - $($_.Title)" }; Write-Output ""; Write-Output "Gesamt: $($r.Updates.Count) Updates ausstehend"`
|
|
||||||
|
|
||||||
const check = async () => {
|
|
||||||
setChecking(true)
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId, `powershell -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`, 120)
|
|
||||||
setOutput(res?.output || res?.data?.output || 'Keine Ausgabe')
|
|
||||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
|
||||||
finally { setChecking(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const install = async () => {
|
|
||||||
if (!confirm('Windows Updates installieren? Der PC kann danach neu starten.')) return
|
|
||||||
setInstalling(true)
|
|
||||||
setOutput('Updates werden installiert — das kann mehrere Minuten dauern...')
|
|
||||||
try {
|
|
||||||
const installScript = `$s=New-Object -ComObject Microsoft.Update.Session; $r=$s.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software'"); if($r.Updates.Count -eq 0){Write-Output "Keine Updates verfuegbar"; exit}; $d=$s.CreateUpdateDownloader(); $d.Updates=$r.Updates; $d.Download(); $i=$s.CreateUpdateInstaller(); $i.Updates=$r.Updates; $ir=$i.Install(); Write-Output "Installiert: $($r.Updates.Count), ResultCode: $($ir.ResultCode)"`
|
|
||||||
const res = await api.execCommand(agentId, `powershell -NonInteractive -Command "${installScript.replace(/"/g, '\\"')}"`, 1800)
|
|
||||||
setOutput(res?.output || res?.data?.output || '')
|
|
||||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
|
||||||
finally { setInstalling(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { check() }, [agentId])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={check} disabled={checking || installing}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? 'animate-spin' : ''}`} />
|
|
||||||
Pruefen
|
|
||||||
</button>
|
|
||||||
<button onClick={install} disabled={checking || installing}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
{installing ? 'Installiere...' : 'Jetzt installieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{output && (
|
|
||||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
|
|
||||||
{output}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── System-Tabs ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ServicesTab({ win }) {
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const services = win?.services || []
|
|
||||||
const filtered = search
|
|
||||||
? services.filter(s => s.name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
s.display_name?.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: services
|
|
||||||
|
|
||||||
if (services.length === 0) return <div className="text-gray-500 text-sm">Keine Dienstdaten verfuegbar</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Dienste suchen..."
|
|
||||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{filtered.map(s => (
|
|
||||||
<div key={s.name} className="flex items-center gap-2.5 px-2 py-1 rounded hover:bg-gray-800/50">
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
||||||
s.status === 'running' ? 'bg-green-400' :
|
|
||||||
s.status === 'stopped' ? 'bg-gray-600' : 'bg-yellow-400'
|
|
||||||
}`} />
|
|
||||||
<span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{s.display_name || s.name}</span>
|
|
||||||
<span className="text-[10px] text-gray-600 flex-shrink-0">{s.start_type}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TunnelTab({ agentId, agent }) {
|
|
||||||
const [showTerminal, setShowTerminal] = useState(false)
|
|
||||||
const [showScript, setShowScript] = useState(false)
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button onClick={() => setShowTerminal(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors">
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
Terminal
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowScript(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors">
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
Script
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showTerminal && <TerminalModal agentId={agentId} agentName={agent?.name || ''} onClose={() => setShowTerminal(false)} />}
|
|
||||||
{showScript && <ScriptModal agentId={agentId} agentName={agent?.name || ''} onClose={() => setShowScript(false)} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TasksTab({ agentId, agentName }) {
|
|
||||||
const [cmd, setCmd] = useState('')
|
|
||||||
const [output, setOutput] = useState(null)
|
|
||||||
const [running, setRunning] = useState(false)
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
if (!cmd.trim()) return
|
|
||||||
setRunning(true)
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId, cmd, 60)
|
|
||||||
setOutput(res?.output || res?.data?.output || '')
|
|
||||||
} catch (e) { setOutput(`Fehler: ${e.message}`) }
|
|
||||||
finally { setRunning(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input value={cmd} onChange={e => setCmd(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && run()}
|
|
||||||
placeholder="Befehl (PowerShell oder cmd)"
|
|
||||||
className="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white font-mono focus:outline-none focus:border-blue-500" />
|
|
||||||
<button onClick={run} disabled={running || !cmd.trim()}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 rounded text-sm text-white transition-colors">
|
|
||||||
<Play className="w-3.5 h-3.5" />
|
|
||||||
{running ? 'Laeuft...' : 'Ausfuehren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{output !== null && (
|
|
||||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-80 overflow-y-auto">
|
|
||||||
{output || '(keine Ausgabe)'}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentInfoTab({ agent, status }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-2">
|
|
||||||
{[
|
|
||||||
['Agent-ID', agent.id],
|
|
||||||
['Name', agent.name],
|
|
||||||
['Hostname', agent.hostname],
|
|
||||||
['IP', agent.ip],
|
|
||||||
['Version', agent.agent_version || '—'],
|
|
||||||
['Plattform', agent.platform],
|
|
||||||
['Status', status],
|
|
||||||
['Registriert', new Date(agent.registered_at).toLocaleString('de-DE')],
|
|
||||||
].map(([k, v]) => (
|
|
||||||
<div key={k} className="flex gap-4">
|
|
||||||
<span className="text-xs text-gray-500 w-28 shrink-0">{k}</span>
|
|
||||||
<span className="text-xs text-gray-300 font-mono break-all">{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WAU-Tab ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function WAUTab({ agentId, customerId }) {
|
|
||||||
const [status, setStatus] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [running, setRunning] = useState(false)
|
|
||||||
const [log, setLog] = useState(null)
|
|
||||||
const [installing, setInstalling] = useState(false)
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
|
||||||
const [allowRules, setAllowRules] = useState([])
|
|
||||||
const [enforcing, setEnforcing] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.getSettings().then(s => {
|
|
||||||
const url = s?.data?.backend_url_external || s?.backend_url_external
|
|
||||||
if (url) setExternalUrl(url.replace(/\/$/, ''))
|
|
||||||
})
|
|
||||||
if (customerId) {
|
|
||||||
api.getWAURules(customerId).then(rules => {
|
|
||||||
setAllowRules((rules || []).filter(r => r.rule_type === 'allow'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [customerId])
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
// Task prüfen (einfachste Methode, kein komplexes Escaping)
|
|
||||||
const taskRes = await api.execCommand(agentId,
|
|
||||||
"if (Get-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate' -ErrorAction SilentlyContinue) { 'TASK_FOUND' } else { 'TASK_NOT_FOUND' }",
|
|
||||||
15)
|
|
||||||
const taskOut = (taskRes?.output || taskRes?.data?.output || '').trim()
|
|
||||||
const taskFound = taskOut.includes('TASK_FOUND')
|
|
||||||
|
|
||||||
if (!taskFound) {
|
|
||||||
setStatus({ Installed: false, Version: '', TaskExists: false, TaskState: '', LastRun: '' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version + letzter Lauf
|
|
||||||
const infoRes = await api.execCommand(agentId,
|
|
||||||
"$t = Get-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate' -EA SilentlyContinue; $i = $t | Get-ScheduledTaskInfo; $v = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -EA SilentlyContinue | Where-Object { $_.DisplayName -like '*Winget-AutoUpdate*' } | Select-Object -First 1).DisplayVersion; $lr = if ($i.LastRunTime.Year -gt 2000) { $i.LastRunTime.ToString('yyyy-MM-dd HH:mm') } else { '-' }; Write-Output \"$v|$($t.State)|$lr\"",
|
|
||||||
15)
|
|
||||||
const infoOut = (infoRes?.output || infoRes?.data?.output || '').trim()
|
|
||||||
const [version, taskState, lastRun] = infoOut.split('|')
|
|
||||||
|
|
||||||
setStatus({ Installed: true, Version: version || '', TaskExists: true, TaskState: taskState || '', LastRun: lastRun || '' })
|
|
||||||
} catch (e) {
|
|
||||||
setStatus(null)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const installWAU = async () => {
|
|
||||||
if (!externalUrl || !customerId) return
|
|
||||||
setInstalling(true)
|
|
||||||
setLog('WAU wird heruntergeladen und installiert — bitte warten (1-3 Minuten)...')
|
|
||||||
const listPath = `${externalUrl}/api/v1/customers/${customerId}/wau/included`
|
|
||||||
const blockPath = `${externalUrl}/api/v1/customers/${customerId}/wau/excluded`
|
|
||||||
// MSI direkt herunterladen + synchron mit msiexec installieren
|
|
||||||
const cmd = `$msi="$env:TEMP\\WAU.msi"; Invoke-WebRequest 'https://github.com/Romanitho/Winget-AutoUpdate/releases/latest/download/WAU.msi' -OutFile $msi -UseBasicParsing; $args=@('/i',$msi,'/qn','NOTIFICATIONLEVEL=2','RUNONSTARTUP=1','UPDATESINTERVAL=Daily','LISTPATH=${listPath}','BLOCKEDAPPSLISTPATH=${blockPath}'); $p=Start-Process msiexec -ArgumentList $args -Wait -PassThru; if(Get-ScheduledTask 'Winget-AutoUpdate' -EA SilentlyContinue){"WAU erfolgreich installiert (ExitCode: $($p.ExitCode))"}else{"Installation fehlgeschlagen (ExitCode: $($p.ExitCode))"}`
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId, cmd, 300)
|
|
||||||
setLog(res?.output || res?.data?.output || '(keine Ausgabe)')
|
|
||||||
setTimeout(load, 3000)
|
|
||||||
} catch (e) {
|
|
||||||
setLog(`Fehler: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
setInstalling(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runNow = async () => {
|
|
||||||
setRunning(true)
|
|
||||||
setLog(null)
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId,
|
|
||||||
"Start-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate'; Start-Sleep 2; Write-Output 'WAU gestartet'",
|
|
||||||
15)
|
|
||||||
setLog(res?.output || res?.data?.output || 'Gestartet')
|
|
||||||
setTimeout(load, 5000) // Status nach 5s neu laden
|
|
||||||
} catch (e) {
|
|
||||||
setLog(`Fehler: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
setRunning(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enforceAllowlist = async () => {
|
|
||||||
if (!allowRules.length) return
|
|
||||||
setEnforcing(true)
|
|
||||||
setLog(`Installiere ${allowRules.length} Pakete aus der Allowlist...\n`)
|
|
||||||
// PowerShell-Loop: jedes Paket einzeln installieren, Ergebnis ausgeben
|
|
||||||
const ids = allowRules.map(r => r.winget_id).join("','")
|
|
||||||
const cmd = `$pkgs=@('${ids}'); foreach($p in $pkgs){ Write-Output ">>> $p"; winget install --id $p --silent --accept-package-agreements --accept-source-agreements 2>&1 | Select-Object -Last 3 | Out-String; Write-Output "" }; Write-Output "=== Fertig ==="`
|
|
||||||
try {
|
|
||||||
const res = await api.execCommand(agentId, cmd, 600)
|
|
||||||
setLog(res?.output || res?.data?.output || '(keine Ausgabe)')
|
|
||||||
} catch (e) {
|
|
||||||
setLog(`Fehler: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
setEnforcing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadLog = async () => {
|
|
||||||
const res = await api.execCommand(agentId,
|
|
||||||
"Get-Content 'C:\\ProgramData\\Winget-AutoUpdate\\Logs\\WAU-updates.log' -Tail 30 -ErrorAction SilentlyContinue",
|
|
||||||
15)
|
|
||||||
setLog(res?.output || res?.data?.output || 'Kein Log gefunden')
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [agentId])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
|
|
||||||
{/* Status-Card */}
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Zap className="w-4 h-4 text-yellow-400" />
|
|
||||||
<span className="text-sm font-medium text-white">Winget-AutoUpdate</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={load} disabled={loading} className="text-gray-600 hover:text-gray-400">
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-gray-500 text-sm">Prüfe Status...</div>
|
|
||||||
) : status === null ? (
|
|
||||||
<div className="text-red-400 text-sm">Status konnte nicht abgerufen werden</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Installiert / nicht installiert */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{status.Installed ? (
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-5 h-5 text-gray-600 shrink-0" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-white">
|
|
||||||
{status.Installed ? 'WAU installiert' : 'WAU nicht installiert'}
|
|
||||||
</div>
|
|
||||||
{status.Version && (
|
|
||||||
<div className="text-xs text-gray-500">Version {status.Version}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task-Status + letzter Lauf */}
|
|
||||||
{status.Installed && (
|
|
||||||
<div className="grid grid-cols-2 gap-3 pt-1">
|
|
||||||
<div className="bg-gray-900/60 rounded p-2">
|
|
||||||
<div className="text-[10px] text-gray-500 mb-1">Geplante Aufgabe</div>
|
|
||||||
<div className={`text-xs font-medium ${
|
|
||||||
status.TaskExists ? 'text-green-400' : 'text-red-400'
|
|
||||||
}`}>
|
|
||||||
{status.TaskExists ? status.TaskState || 'Vorhanden' : 'Fehlt'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-900/60 rounded p-2">
|
|
||||||
<div className="text-[10px] text-gray-500 mb-1">Letzter Lauf</div>
|
|
||||||
<div className="text-xs text-gray-300 flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3 text-gray-600" />
|
|
||||||
{status.LastRun || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Aktionen */}
|
|
||||||
<div className="flex gap-2 pt-1">
|
|
||||||
{status.Installed ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={runNow}
|
|
||||||
disabled={running}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Zap className={`w-3.5 h-3.5 ${running ? 'animate-pulse' : ''}`} />
|
|
||||||
{running ? 'Läuft...' : 'Jetzt ausführen'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={loadLog}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<Search className="w-3.5 h-3.5" />
|
|
||||||
Log anzeigen
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{externalUrl && customerId ? (
|
|
||||||
<button
|
|
||||||
onClick={installWAU}
|
|
||||||
disabled={installing}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Download className={`w-3.5 h-3.5 ${installing ? 'animate-bounce' : ''}`} />
|
|
||||||
{installing ? 'Installiere...' : 'WAU jetzt installieren'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{!externalUrl
|
|
||||||
? 'Externe Backend-URL nicht konfiguriert (Einstellungen)'
|
|
||||||
: 'Kein Kunde zugeordnet — Agent zuerst einem Kunden zuweisen'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kunden-Listen Hinweis */}
|
|
||||||
{status.Installed && customerId && (
|
|
||||||
<div className="text-[10px] text-gray-600 border-t border-gray-700/50 pt-2 mt-1">
|
|
||||||
Allow/Blocklisten werden verwaltet unter: Kunden → Kunde auswählen → WAU-Tab
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Allowlist Desired State */}
|
|
||||||
{customerId && (
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="w-4 h-4 text-green-400" />
|
|
||||||
<span className="text-sm font-medium text-white">Gewünschten Zustand herstellen</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-gray-600">{allowRules.length} Pakete in Allowlist</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allowRules.length === 0 ? (
|
|
||||||
<div className="text-xs text-gray-600">Keine Allowlist konfiguriert — in der Kundenverwaltung unter WAU hinterlegen.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
|
|
||||||
{allowRules.map(r => (
|
|
||||||
<span key={r.id} className="text-[10px] font-mono px-1.5 py-0.5 bg-gray-900 border border-gray-700 rounded text-gray-400">
|
|
||||||
{r.winget_id}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={enforceAllowlist}
|
|
||||||
disabled={enforcing}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-700 hover:bg-green-600 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Download className={`w-3.5 h-3.5 ${enforcing ? 'animate-bounce' : ''}`} />
|
|
||||||
{enforcing ? 'Installiere...' : 'Fehlende Software installieren'}
|
|
||||||
</button>
|
|
||||||
<span className="text-[10px] text-gray-600">Bereits installierte Pakete werden übersprungen</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Log-Ausgabe */}
|
|
||||||
{log && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 mb-1">Log-Ausgabe</div>
|
|
||||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-64 overflow-y-auto">
|
|
||||||
{log}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hilfskomponenten ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, sub, color, pct }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Icon className={`w-4 h-4 ${color}`} />
|
|
||||||
<span className="text-xs text-gray-500">{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
|
||||||
{sub && <div className="text-xs text-gray-600 mt-0.5 truncate">{sub}</div>}
|
|
||||||
{pct !== undefined && <UsageBar pct={pct} color={color.replace('text-', 'bg-')} className="mt-2" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageBar({ pct, color, className = '' }) {
|
|
||||||
return (
|
|
||||||
<div className={`w-full bg-gray-700 rounded-full h-1.5 ${className}`}>
|
|
||||||
<div className={`h-1.5 rounded-full transition-all ${color}`}
|
|
||||||
style={{ width: `${Math.min(100, pct ?? 0).toFixed(0)}%` }} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,21 +1,8 @@
|
|||||||
// Frontend-Konfiguration — als config.js speichern vor dem Build
|
// Frontend-Konfiguration — wird automatisch aus .env gelesen
|
||||||
// config.js ist in .gitignore und wird NICHT eingecheckt
|
// Kopiere frontend/.env.example → frontend/.env und trage deine Werte ein
|
||||||
//
|
// config.js und .env sind in .gitignore und werden NICHT eingecheckt
|
||||||
// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime-Injection)
|
|
||||||
// 2. Vite ENV-Variablen (.env / --env beim Build)
|
|
||||||
// 3. Fallback-Werte hier
|
|
||||||
//
|
|
||||||
// Docker-Beispiel (nginx config.js-Injection):
|
|
||||||
// window.__RMM_CONFIG__ = { backendHost: "192.168.1.10", backendPort: "8443", apiKey: "abc123" }
|
|
||||||
//
|
|
||||||
// .env-Beispiel:
|
|
||||||
// VITE_BACKEND_HOST=192.168.1.10
|
|
||||||
// VITE_BACKEND_PORT=8443
|
|
||||||
// VITE_API_KEY=abc123
|
|
||||||
|
|
||||||
const rt = window.__RMM_CONFIG__ || {}
|
export const BACKEND_HOST = import.meta.env.VITE_BACKEND_HOST || 'localhost'
|
||||||
|
export const BACKEND_PORT = import.meta.env.VITE_BACKEND_PORT || '8443'
|
||||||
export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
|
|
||||||
export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
|
|
||||||
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
|
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
|
||||||
export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''
|
export const API_KEY = import.meta.env.VITE_API_KEY || ''
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
// Frontend-Konfiguration
|
|
||||||
// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime), 2. Vite ENV (lokaler Build)
|
|
||||||
const rt = window.__RMM_CONFIG__ || {}
|
|
||||||
|
|
||||||
export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
|
|
||||||
export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
|
|
||||||
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
|
|
||||||
export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''
|
|
||||||
@ -14,13 +14,11 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Monitor,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/agents', label: 'Firewalls', icon: Server },
|
{ path: '/agents', label: 'Firewalls', icon: Server },
|
||||||
{ path: '/windows', label: 'Windows', icon: Monitor },
|
|
||||||
{ path: '/proxmox', label: 'Proxmox', icon: HardDrive },
|
{ path: '/proxmox', label: 'Proxmox', icon: HardDrive },
|
||||||
{ path: '/tunnels', label: 'Tunnel', icon: Cable },
|
{ path: '/tunnels', label: 'Tunnel', icon: Cable },
|
||||||
{ path: '/firmware', label: 'Firmware', icon: Download },
|
{ path: '/firmware', label: 'Firmware', icon: Download },
|
||||||
|
|||||||
@ -126,7 +126,7 @@ export default function Agents() {
|
|||||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||||
|
|
||||||
// Nur OPNsense/FreeBSD Agents anzeigen
|
// Nur OPNsense/FreeBSD Agents anzeigen
|
||||||
if (a.platform === 'linux' || a.platform === 'windows') return null
|
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
|
||||||
|
|||||||
@ -1,53 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { Plus, Trash2, Edit2, X, Check } from 'lucide-react'
|
||||||
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, ShieldAlert, Package, Search, RefreshCw, CircleCheck, CircleX, CircleAlert, Info } from 'lucide-react'
|
|
||||||
|
|
||||||
const PERM_LABELS = {
|
|
||||||
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
|
|
||||||
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50' },
|
|
||||||
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50' },
|
|
||||||
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WAU Compliance Badge ──────────────────────────────────────────────────────
|
|
||||||
function WAUComplianceBadge({ summary }) {
|
|
||||||
if (!summary) return null
|
|
||||||
if (summary.total_agents === 0) return null
|
|
||||||
|
|
||||||
const { status, compliant_agents, total_agents, wau_missing, wau_misconfigured, packages_missing, updates_pending } = summary
|
|
||||||
|
|
||||||
const configs = {
|
|
||||||
ok: { icon: CircleCheck, cls: 'text-green-400', bg: 'bg-green-900/20 border-green-700/40', label: `WAU OK (${compliant_agents}/${total_agents})` },
|
|
||||||
warning: { icon: CircleAlert, cls: 'text-yellow-400', bg: 'bg-yellow-900/20 border-yellow-700/40', label: buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) },
|
|
||||||
critical: { icon: CircleX, cls: 'text-red-400', bg: 'bg-red-900/20 border-red-700/40', label: buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) },
|
|
||||||
unknown: { icon: Info, cls: 'text-gray-500', bg: 'bg-gray-800/40 border-gray-700/40', label: 'Keine Windows-Agents' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = configs[status] || configs.unknown
|
|
||||||
const Icon = cfg.icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] font-medium ${cfg.cls} ${cfg.bg}`}>
|
|
||||||
<Icon className="w-3 h-3" />
|
|
||||||
{cfg.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) {
|
|
||||||
const parts = []
|
|
||||||
if (wau_misconfigured > 0) parts.push(`${wau_misconfigured} fehlkonfiguriert`)
|
|
||||||
if (updates_pending > 0) parts.push(`${updates_pending} Updates`)
|
|
||||||
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) {
|
|
||||||
const parts = []
|
|
||||||
if (wau_missing > 0) parts.push(`${wau_missing} ohne WAU`)
|
|
||||||
if (packages_missing > 0) parts.push(`${packages_missing} Pakete fehlen`)
|
|
||||||
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Customers() {
|
export default function Customers() {
|
||||||
const [customers, setCustomers] = useState([])
|
const [customers, setCustomers] = useState([])
|
||||||
@ -55,22 +8,12 @@ export default function Customers() {
|
|||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [editId, setEditId] = useState(null)
|
const [editId, setEditId] = useState(null)
|
||||||
const [form, setForm] = useState({ number: '', name: '' })
|
const [form, setForm] = useState({ number: '', name: '' })
|
||||||
const [expandedId, setExpandedId] = useState(null)
|
|
||||||
const [complianceSummaries, setComplianceSummaries] = useState({}) // customerID → summary
|
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
|
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadCompliance = () => {
|
useEffect(() => { reload() }, [])
|
||||||
api.getWAUComplianceSummary().then(data => {
|
|
||||||
const map = {}
|
|
||||||
;(data || []).forEach(s => { map[s.customer_id] = s })
|
|
||||||
setComplianceSummaries(map)
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { reload(); loadCompliance() }, [])
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!form.number || !form.name) return
|
if (!form.number || !form.name) return
|
||||||
@ -92,10 +35,6 @@ export default function Customers() {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleExpand = (id) => {
|
|
||||||
setExpandedId(prev => prev === id ? null : id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -113,10 +52,8 @@ export default function Customers() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800 text-left text-gray-500">
|
<tr className="border-b border-gray-800 text-left text-gray-500">
|
||||||
<th className="px-4 py-2 font-medium w-6"></th>
|
|
||||||
<th className="px-4 py-2 font-medium">Nummer</th>
|
<th className="px-4 py-2 font-medium">Nummer</th>
|
||||||
<th className="px-4 py-2 font-medium">Name</th>
|
<th className="px-4 py-2 font-medium">Name</th>
|
||||||
<th className="px-4 py-2 font-medium hidden lg:table-cell">WAU</th>
|
|
||||||
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
|
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
|
||||||
<th className="px-4 py-2 font-medium w-24"></th>
|
<th className="px-4 py-2 font-medium w-24"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -124,7 +61,6 @@ export default function Customers() {
|
|||||||
<tbody className="divide-y divide-gray-800">
|
<tbody className="divide-y divide-gray-800">
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -142,7 +78,6 @@ export default function Customers() {
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden md:table-cell"></td>
|
<td className="hidden md:table-cell"></td>
|
||||||
@ -159,10 +94,9 @@ export default function Customers() {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{customers.map((c) => (
|
{customers.map((c) => (
|
||||||
<>
|
<tr key={c.id} className="hover:bg-gray-800/50">
|
||||||
{editId === c.id ? (
|
{editId === c.id ? (
|
||||||
<tr key={`edit-${c.id}`} className="bg-gray-800/30">
|
<>
|
||||||
<td></td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -190,53 +124,30 @@ export default function Customers() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<tr
|
<>
|
||||||
key={`row-${c.id}`}
|
<td className="px-4 py-2 text-orange-400 font-mono">{c.number}</td>
|
||||||
className={`hover:bg-gray-800/50 cursor-pointer transition-colors ${expandedId === c.id ? 'bg-gray-800/30' : ''}`}
|
<td className="px-4 py-2 text-white">{c.name}</td>
|
||||||
onClick={() => toggleExpand(c.id)}
|
<td className="px-4 py-2 text-gray-500 text-xs hidden md:table-cell">
|
||||||
>
|
|
||||||
<td className="px-3 py-2.5 text-gray-600">
|
|
||||||
{expandedId === c.id
|
|
||||||
? <ChevronDown className="w-3.5 h-3.5" />
|
|
||||||
: <ChevronRight className="w-3.5 h-3.5" />
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-orange-400 font-mono">{c.number}</td>
|
|
||||||
<td className="px-4 py-2.5 text-white">{c.name}</td>
|
|
||||||
<td className="px-4 py-2.5 hidden lg:table-cell">
|
|
||||||
<WAUComplianceBadge summary={complianceSummaries[c.id]} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
|
|
||||||
{new Date(c.created_at).toLocaleDateString('de-DE')}
|
{new Date(c.created_at).toLocaleDateString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
|
<td className="px-4 py-2">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }}
|
onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }}
|
||||||
className="text-gray-500 hover:text-gray-300 p-1 rounded hover:bg-gray-700"
|
className="text-gray-500 hover:text-gray-300"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => handleDelete(c.id)} className="text-gray-500 hover:text-red-400">
|
||||||
onClick={() => handleDelete(c.id)}
|
<Trash2 className="w-4 h-4" />
|
||||||
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</>
|
||||||
)}
|
)}
|
||||||
{expandedId === c.id && (
|
</tr>
|
||||||
<tr key={`expand-${c.id}`}>
|
|
||||||
<td colSpan={6} className="px-0 py-0">
|
|
||||||
<CustomerDetailPanel customerId={c.id} customerNumber={c.number} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -247,670 +158,3 @@ export default function Customers() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CustomerDetailPanel ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CustomerDetailPanel({ customerId, customerNumber }) {
|
|
||||||
const [activeTab, setActiveTab] = useState('agents')
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'agents', label: 'Agents', icon: Monitor },
|
|
||||||
{ id: 'apikeys', label: 'API-Keys', icon: Key },
|
|
||||||
{ id: 'wau', label: 'WAU', icon: Package },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-800/20 border-t border-gray-800">
|
|
||||||
{/* Tab-Leiste */}
|
|
||||||
<div className="flex gap-0 border-b border-gray-800 px-6">
|
|
||||||
{tabs.map(t => {
|
|
||||||
const Icon = t.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setActiveTab(t.id) }}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-2 text-xs border-b-2 transition-colors ${
|
|
||||||
activeTab === t.id
|
|
||||||
? 'text-orange-400 border-orange-400'
|
|
||||||
: 'text-gray-500 border-transparent hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-3 h-3" />
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inhalt */}
|
|
||||||
<div onClick={e => e.stopPropagation()}>
|
|
||||||
{activeTab === 'agents' && <AgentsTab customerId={customerId} />}
|
|
||||||
{activeTab === 'apikeys' && <APIKeysTab customerId={customerId} />}
|
|
||||||
{activeTab === 'wau' && <WauTab customerId={customerId} customerNumber={customerNumber} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agents-Tab ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AgentsTab({ customerId }) {
|
|
||||||
const [keys, setKeys] = useState(null)
|
|
||||||
const [agents, setAgents] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.getCustomerAPIKeys(customerId).then(setKeys)
|
|
||||||
api.getCustomerAgents(customerId).then(setAgents)
|
|
||||||
}, [customerId])
|
|
||||||
|
|
||||||
if (!keys || !agents) {
|
|
||||||
return <div className="px-8 py-3 text-gray-500 text-xs">Laden...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentKey = keys.find(k => k.permissions === 'agent')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-8 py-3 space-y-1.5">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Monitor className="w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-500 font-medium">Agents ({agents.length})</span>
|
|
||||||
</div>
|
|
||||||
{agents.length === 0 ? (
|
|
||||||
<div className="text-xs text-gray-600">Keine Agents zugeordnet</div>
|
|
||||||
) : (
|
|
||||||
agents.map((a) => {
|
|
||||||
const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key
|
|
||||||
const unknownKey = agentKey && !a.last_api_key
|
|
||||||
return (
|
|
||||||
<div key={a.id} className={`flex items-center gap-2.5 rounded px-1.5 py-0.5 -mx-1.5 ${wrongKey ? 'bg-red-900/20' : ''}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
||||||
a.status === 'online' ? 'bg-green-400' :
|
|
||||||
a.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
|
|
||||||
}`} />
|
|
||||||
<span className={`text-xs flex-1 min-w-0 truncate ${wrongKey ? 'text-red-300' : 'text-gray-300'}`}>{a.name}</span>
|
|
||||||
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">{a.agent_version || '—'}</span>
|
|
||||||
{wrongKey && <span title={`Falscher Key: ${a.last_api_key?.substring(0,8)}...`}><AlertTriangle className="w-3 h-3 text-red-400 flex-shrink-0" /></span>}
|
|
||||||
{unknownKey && <span title="Noch kein Heartbeat mit Key-Info"><AlertTriangle className="w-3 h-3 text-gray-600 flex-shrink-0" /></span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API-Keys-Tab ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function APIKeysTab({ customerId }) {
|
|
||||||
const [keys, setKeys] = useState(null)
|
|
||||||
const [copied, setCopied] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.getCustomerAPIKeys(customerId).then(setKeys)
|
|
||||||
}, [customerId])
|
|
||||||
|
|
||||||
const copyKey = (key, id) => {
|
|
||||||
copyToClipboard(key)
|
|
||||||
setCopied(id)
|
|
||||||
setTimeout(() => setCopied(null), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!keys) return <div className="px-8 py-3 text-gray-500 text-xs">Laden...</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-8 py-3 space-y-1.5">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Key className="w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-500 font-medium">API-Keys</span>
|
|
||||||
</div>
|
|
||||||
{keys.length === 0 ? (
|
|
||||||
<div className="text-xs text-gray-600">Keine Keys</div>
|
|
||||||
) : (
|
|
||||||
keys.map((k) => {
|
|
||||||
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
|
||||||
return (
|
|
||||||
<div key={k.id} className="flex items-center gap-2.5">
|
|
||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
|
|
||||||
{perm.label}
|
|
||||||
</span>
|
|
||||||
<code className="text-xs text-gray-500 font-mono flex-1 min-w-0 truncate">
|
|
||||||
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
onClick={() => copyKey(k.key, k.id)}
|
|
||||||
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0"
|
|
||||||
title="Key kopieren"
|
|
||||||
>
|
|
||||||
{copied === k.id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WAU Compliance Panel ──────────────────────────────────────────────────────
|
|
||||||
function WAUCompliancePanel({ compliance, loading, onRefresh }) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
|
|
||||||
if (loading) return (
|
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3 text-xs text-gray-500 flex items-center gap-2">
|
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" /> Compliance wird geprueft...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const agents = compliance?.agents || []
|
|
||||||
if (agents.length === 0) return null
|
|
||||||
|
|
||||||
const compliantCount = agents.filter(a => a.compliant).length
|
|
||||||
const allOk = compliantCount === agents.length
|
|
||||||
|
|
||||||
const overallStatus = agents.some(a => !a.wau_installed || a.packages?.some(p => !p.installed))
|
|
||||||
? 'critical'
|
|
||||||
: agents.some(a => !a.wau_config_ok || a.pending_updates > 0)
|
|
||||||
? 'warning'
|
|
||||||
: 'ok'
|
|
||||||
|
|
||||||
const statusColors = {
|
|
||||||
ok: 'border-green-700/40 bg-green-900/10',
|
|
||||||
warning: 'border-yellow-700/40 bg-yellow-900/10',
|
|
||||||
critical: 'border-red-700/40 bg-red-900/10',
|
|
||||||
}
|
|
||||||
const headerColors = {
|
|
||||||
ok: 'text-green-400',
|
|
||||||
warning: 'text-yellow-400',
|
|
||||||
critical: 'text-red-400',
|
|
||||||
}
|
|
||||||
const StatusIcon = overallStatus === 'ok' ? CircleCheck : overallStatus === 'warning' ? CircleAlert : CircleX
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-lg border p-3 ${statusColors[overallStatus]}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusIcon className={`w-4 h-4 ${headerColors[overallStatus]}`} />
|
|
||||||
<span className={`text-xs font-medium ${headerColors[overallStatus]}`}>
|
|
||||||
WAU Compliance: {compliantCount}/{agents.length} Agents konform
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={onRefresh} className="text-gray-600 hover:text-gray-400 transition-colors">
|
|
||||||
<RefreshCw className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(v => !v)}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{agents.map(agent => (
|
|
||||||
<AgentComplianceRow key={agent.agent_id} agent={agent} expectedIncludeUrl={compliance?.include_url} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentComplianceRow({ agent, expectedIncludeUrl }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const missingPkgs = (agent.packages || []).filter(p => !p.installed)
|
|
||||||
const hasIssues = !agent.wau_installed || !agent.wau_config_ok || missingPkgs.length > 0 || agent.pending_updates > 0
|
|
||||||
|
|
||||||
const rowColor = agent.compliant ? 'text-green-400' : hasIssues ? 'text-red-400' : 'text-yellow-400'
|
|
||||||
const RowIcon = agent.compliant ? CircleCheck : CircleX
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-900/60 rounded border border-gray-700/50">
|
|
||||||
<button
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-800/40 transition-colors"
|
|
||||||
onClick={() => setOpen(v => !v)}
|
|
||||||
>
|
|
||||||
<RowIcon className={`w-3.5 h-3.5 flex-shrink-0 ${rowColor}`} />
|
|
||||||
<span className="text-xs text-white font-mono flex-1">{agent.agent_name}</span>
|
|
||||||
<div className="flex gap-1 flex-wrap justify-end">
|
|
||||||
{!agent.wau_installed && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">WAU fehlt</span>
|
|
||||||
)}
|
|
||||||
{agent.wau_installed && !agent.wau_config_ok && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">Falsche URLs</span>
|
|
||||||
)}
|
|
||||||
{missingPkgs.length > 0 && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">{missingPkgs.length} Paket(e) fehlen</span>
|
|
||||||
)}
|
|
||||||
{agent.pending_updates > 0 && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">{agent.pending_updates} Updates</span>
|
|
||||||
)}
|
|
||||||
{agent.compliant && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/40 border border-green-700/40 text-green-400">OK</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{open ? <ChevronDown className="w-3 h-3 text-gray-600 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-gray-600 flex-shrink-0" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="px-4 pb-3 pt-1 space-y-2 border-t border-gray-700/40">
|
|
||||||
{/* WAU Status */}
|
|
||||||
<div className="text-[10px] text-gray-400 space-y-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{agent.wau_installed
|
|
||||||
? <CircleCheck className="w-3 h-3 text-green-400" />
|
|
||||||
: <CircleX className="w-3 h-3 text-red-400" />
|
|
||||||
}
|
|
||||||
<span>WAU {agent.wau_installed ? `installiert` : 'nicht installiert'}</span>
|
|
||||||
</div>
|
|
||||||
{agent.wau_installed && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{agent.wau_config_ok
|
|
||||||
? <CircleCheck className="w-3 h-3 text-green-400" />
|
|
||||||
: <CircleX className="w-3 h-3 text-red-400" />
|
|
||||||
}
|
|
||||||
<span>
|
|
||||||
Konfiguration {agent.wau_config_ok ? 'korrekt' : 'falsch'}
|
|
||||||
{!agent.wau_config_ok && agent.include_url && (
|
|
||||||
<span className="text-gray-600 ml-1">({agent.include_url || 'keine URL'})</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{agent.pending_updates > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<CircleAlert className="w-3 h-3 text-yellow-400" />
|
|
||||||
<span>{agent.pending_updates} ausstehende Updates</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Paket-Status */}
|
|
||||||
{(agent.packages || []).length > 0 && (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-[10px] text-gray-600 font-medium uppercase tracking-wide mb-1">Pflichtpakete</div>
|
|
||||||
{(agent.packages || []).map(pkg => (
|
|
||||||
<div key={pkg.winget_id} className="flex items-center gap-1.5 text-[10px]">
|
|
||||||
{pkg.installed
|
|
||||||
? <CircleCheck className="w-3 h-3 text-green-400 flex-shrink-0" />
|
|
||||||
: <CircleX className="w-3 h-3 text-red-400 flex-shrink-0" />
|
|
||||||
}
|
|
||||||
<span className={pkg.installed ? 'text-gray-400' : 'text-red-300'}>{pkg.display_name}</span>
|
|
||||||
<span className="text-gray-700 font-mono">{pkg.winget_id}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WAU-Tab ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Heuristik: Anzeigename → winget-ID-Vorschlag
|
|
||||||
function guessWingetId(name) {
|
|
||||||
// Versionsnummer am Ende entfernen: "Mozilla Firefox 134.0.1" → "Mozilla Firefox"
|
|
||||||
const clean = name.replace(/\s+\d[\d.]*(\s+\(.*\))?$/, '').trim()
|
|
||||||
// "Publisher Name" → "Publisher.Name" (erste zwei Wörter)
|
|
||||||
const parts = clean.split(/\s+/)
|
|
||||||
if (parts.length >= 2) return `${parts[0]}.${parts.slice(1).join('')}`
|
|
||||||
return clean
|
|
||||||
}
|
|
||||||
|
|
||||||
function WauTab({ customerId, customerNumber }) {
|
|
||||||
const [rules, setRules] = useState(null)
|
|
||||||
const [inventory, setInventory] = useState(null)
|
|
||||||
const [invSearch, setInvSearch] = useState('')
|
|
||||||
const [loadingInv, setLoadingInv] = useState(false)
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
|
||||||
// Inline-Assign: { index, ruleType, wingetId, displayName }
|
|
||||||
const [pending, setPending] = useState(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
// Manuelles Formular (Fallback)
|
|
||||||
const [showManual, setShowManual] = useState(false)
|
|
||||||
const [manualForm, setManualForm] = useState({ wingetId: '', displayName: '', ruleType: 'allow' })
|
|
||||||
// Compliance
|
|
||||||
const [compliance, setCompliance] = useState(null)
|
|
||||||
const [loadingComp, setLoadingComp] = useState(false)
|
|
||||||
const [showCompliance, setShowCompliance] = useState(false)
|
|
||||||
|
|
||||||
const reload = () => api.getWAURules(customerId).then(setRules)
|
|
||||||
|
|
||||||
const loadInventory = () => {
|
|
||||||
setLoadingInv(true)
|
|
||||||
api.getWAUInventory(customerId).then(setInventory).finally(() => setLoadingInv(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadCompliance = () => {
|
|
||||||
setLoadingComp(true)
|
|
||||||
api.getWAUCompliance(customerId).then(setCompliance).finally(() => setLoadingComp(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reload()
|
|
||||||
loadInventory()
|
|
||||||
loadCompliance()
|
|
||||||
api.getSettings().then(s => {
|
|
||||||
const url = s?.data?.backend_url_external || s?.backend_url_external
|
|
||||||
if (url) setExternalUrl(url.replace(/\/$/, ''))
|
|
||||||
})
|
|
||||||
}, [customerId])
|
|
||||||
|
|
||||||
const handleDelete = async (ruleId) => {
|
|
||||||
await api.deleteWAURule(customerId, ruleId)
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline-Assign starten
|
|
||||||
const startAssign = (entry, idx, ruleType) => {
|
|
||||||
setPending({ index: idx, ruleType, wingetId: guessWingetId(entry.name), displayName: entry.name })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline-Assign bestätigen
|
|
||||||
const confirmAssign = async () => {
|
|
||||||
if (!pending?.wingetId.trim()) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await api.addWAURule(customerId, pending.wingetId.trim(), pending.displayName.trim(), pending.ruleType)
|
|
||||||
setPending(null)
|
|
||||||
reload()
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manuell hinzufügen
|
|
||||||
const handleManualAdd = async () => {
|
|
||||||
if (!manualForm.wingetId.trim()) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await api.addWAURule(customerId, manualForm.wingetId.trim(), manualForm.displayName.trim(), manualForm.ruleType)
|
|
||||||
setManualForm({ wingetId: '', displayName: '', ruleType: 'allow' })
|
|
||||||
setShowManual(false)
|
|
||||||
reload()
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowRules = (rules || []).filter(r => r.rule_type === 'allow')
|
|
||||||
const blockRules = (rules || []).filter(r => r.rule_type === 'block')
|
|
||||||
|
|
||||||
const filteredInv = (inventory || []).filter(e =>
|
|
||||||
!invSearch ||
|
|
||||||
e.name.toLowerCase().includes(invSearch.toLowerCase()) ||
|
|
||||||
(e.publisher || '').toLowerCase().includes(invSearch.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
const baseUrl = externalUrl || window.location.origin
|
|
||||||
const wauCmd = externalUrl
|
|
||||||
? `winget install Romanitho.Winget-AutoUpdate --silent --accept-package-agreements --accept-source-agreements --override "/qn NOTIFICATIONLEVEL=2 RUNONSTARTUP=1 UPDATESINTERVAL=Daily LISTPATH=${baseUrl}/api/v1/customers/${customerId}/wau/included BLOCKEDAPPSLISTPATH=${baseUrl}/api/v1/customers/${customerId}/wau/excluded"`
|
|
||||||
: '⚠ Externe Backend-URL nicht konfiguriert — bitte unter Einstellungen → Backend-URL (extern) eintragen'
|
|
||||||
|
|
||||||
const copyCmd = () => {
|
|
||||||
copyToClipboard(wauCmd)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-6 py-4 space-y-4">
|
|
||||||
|
|
||||||
{/* WAU Compliance Status */}
|
|
||||||
<WAUCompliancePanel compliance={compliance} loading={loadingComp} onRefresh={loadCompliance} />
|
|
||||||
|
|
||||||
{/* Installationsbefehl */}
|
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs text-gray-400 font-medium">WAU-Installationsbefehl</span>
|
|
||||||
<button onClick={copyCmd} className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-orange-400 transition-colors">
|
|
||||||
{copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
|
|
||||||
{copied ? 'Kopiert' : 'Kopieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<code className="text-[10px] text-gray-400 font-mono break-all leading-relaxed">{wauCmd}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
|
|
||||||
{/* Linke Spalte: Regeln */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<RuleList title="Allowlist" icon={ShieldCheck} color="text-green-400" rules={allowRules} onDelete={handleDelete} />
|
|
||||||
<RuleList title="Blocklist" icon={ShieldX} color="text-red-400" rules={blockRules} onDelete={handleDelete} />
|
|
||||||
|
|
||||||
{/* Manuell hinzufügen (Fallback) */}
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowManual(v => !v)}
|
|
||||||
className="text-[10px] text-gray-600 hover:text-gray-400 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
Manuell per winget-ID hinzufügen
|
|
||||||
</button>
|
|
||||||
{showManual && (
|
|
||||||
<div className="mt-2 bg-gray-900 rounded-lg border border-gray-700 p-3 space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={manualForm.ruleType}
|
|
||||||
onChange={e => setManualForm(f => ({ ...f, ruleType: e.target.value }))}
|
|
||||||
className="bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500 w-24 shrink-0"
|
|
||||||
>
|
|
||||||
<option value="allow">Allowlist</option>
|
|
||||||
<option value="block">Blocklist</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
value={manualForm.wingetId}
|
|
||||||
onChange={e => setManualForm(f => ({ ...f, wingetId: e.target.value }))}
|
|
||||||
placeholder="Mozilla.Firefox"
|
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white font-mono focus:outline-none focus:border-orange-500 min-w-0"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={manualForm.displayName}
|
|
||||||
onChange={e => setManualForm(f => ({ ...f, displayName: e.target.value }))}
|
|
||||||
placeholder="Anzeigename (optional)"
|
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleManualAdd}
|
|
||||||
disabled={!manualForm.wingetId.trim() || saving}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 rounded text-xs text-white transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
<Check className="w-3 h-3" />
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rechte Spalte: Software-Inventar */}
|
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden flex flex-col max-h-[520px]">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700 shrink-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Package className="w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-400 font-medium">
|
|
||||||
Software-Inventar ({inventory ? filteredInv.length : '…'})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={loadInventory} disabled={loadingInv} className="text-gray-600 hover:text-gray-400">
|
|
||||||
<RefreshCw className={`w-3 h-3 ${loadingInv ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-2 py-1.5 border-b border-gray-800 shrink-0">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2 top-1.5 w-3 h-3 text-gray-600" />
|
|
||||||
<input
|
|
||||||
value={invSearch}
|
|
||||||
onChange={e => setInvSearch(e.target.value)}
|
|
||||||
placeholder="Suchen..."
|
|
||||||
className="w-full pl-6 pr-2 py-1 bg-gray-800 border border-gray-700 rounded text-xs text-white focus:outline-none focus:border-orange-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1">
|
|
||||||
{loadingInv ? (
|
|
||||||
<div className="px-3 py-4 text-xs text-gray-500">Lade Inventar...</div>
|
|
||||||
) : !inventory || inventory.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-xs text-gray-600">
|
|
||||||
Kein Inventar — Agent noch nicht aktualisiert oder kein Heartbeat.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<tbody>
|
|
||||||
{filteredInv.map((e, i) => {
|
|
||||||
const existingRule = (rules || []).find(r => r.display_name === e.name)
|
|
||||||
const isPending = pending?.index === i
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Haupt-Zeile */}
|
|
||||||
<tr
|
|
||||||
key={`row-${i}`}
|
|
||||||
className={`border-b border-gray-800/50 group transition-colors ${isPending ? 'bg-gray-800/60' : 'hover:bg-gray-800/40'}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-1.5 w-full">
|
|
||||||
<div className="text-gray-300 truncate" title={e.name}>{e.name}</div>
|
|
||||||
{e.publisher && <div className="text-gray-600 text-[10px] truncate">{e.publisher}</div>}
|
|
||||||
</td>
|
|
||||||
<td className="px-1 py-1.5 text-gray-600 text-right shrink-0 whitespace-nowrap">
|
|
||||||
<span className="text-[10px]">{e.device_count}x</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 shrink-0 whitespace-nowrap">
|
|
||||||
{existingRule ? (
|
|
||||||
// Schon zugeordnet — Badge + Entfernen
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
|
||||||
existingRule.rule_type === 'allow'
|
|
||||||
? 'bg-green-900/40 text-green-400'
|
|
||||||
: 'bg-red-900/40 text-red-400'
|
|
||||||
}`}>
|
|
||||||
{existingRule.rule_type === 'allow' ? 'Allow' : 'Block'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(existingRule.id)}
|
|
||||||
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
title="Regel entfernen"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : isPending ? (
|
|
||||||
// Inline-Assign offen → Abbrechen
|
|
||||||
<button onClick={() => setPending(null)} className="text-gray-500 hover:text-gray-300">
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
// Allow / Block Buttons on hover
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
|
||||||
<button
|
|
||||||
onClick={() => startAssign(e, i, 'allow')}
|
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 hover:bg-green-800/50 font-medium"
|
|
||||||
title="Zur Allowlist hinzufügen"
|
|
||||||
>
|
|
||||||
Allow
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => startAssign(e, i, 'block')}
|
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/30 text-red-400 hover:bg-red-800/50 font-medium"
|
|
||||||
title="Zur Blocklist hinzufügen"
|
|
||||||
>
|
|
||||||
Block
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Inline-Assign Zeile */}
|
|
||||||
{isPending && (
|
|
||||||
<tr key={`pending-${i}`} className="border-b border-orange-500/30 bg-gray-800/80">
|
|
||||||
<td colSpan={3} className="px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-[10px] font-bold shrink-0 px-1.5 py-0.5 rounded ${
|
|
||||||
pending.ruleType === 'allow'
|
|
||||||
? 'bg-green-900/40 text-green-400'
|
|
||||||
: 'bg-red-900/40 text-red-400'
|
|
||||||
}`}>
|
|
||||||
{pending.ruleType === 'allow' ? 'Allow' : 'Block'}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={pending.wingetId}
|
|
||||||
onChange={e => setPending(p => ({ ...p, wingetId: e.target.value }))}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') confirmAssign(); if (e.key === 'Escape') setPending(null) }}
|
|
||||||
placeholder="winget-ID bestätigen"
|
|
||||||
className="flex-1 bg-gray-700 border border-orange-500/50 rounded px-2 py-1 text-xs text-white font-mono focus:outline-none focus:border-orange-400 min-w-0"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={confirmAssign}
|
|
||||||
disabled={!pending.wingetId.trim() || saving}
|
|
||||||
className="shrink-0 p-1 rounded bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 text-white transition-colors"
|
|
||||||
title="Speichern"
|
|
||||||
>
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-gray-500 mt-1 pl-[60px]">
|
|
||||||
Winget-ID anpassen falls nötig, dann bestätigen (Enter)
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RuleList ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RuleList({ title, icon: Icon, color, rules, onDelete }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-gray-700">
|
|
||||||
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
|
||||||
<span className="text-xs text-gray-400 font-medium">{title}</span>
|
|
||||||
<span className="ml-auto text-[10px] text-gray-600">{rules.length} Einträge</span>
|
|
||||||
</div>
|
|
||||||
{rules.length === 0 ? (
|
|
||||||
<div className="px-3 py-3 text-xs text-gray-600">Keine Einträge</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-gray-800">
|
|
||||||
{rules.map(r => (
|
|
||||||
<div key={r.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-800/40 group">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs text-white truncate">{r.display_name || r.winget_id}</div>
|
|
||||||
<div className="text-[10px] text-gray-600 font-mono">{r.winget_id}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(r.id)}
|
|
||||||
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,8 +3,7 @@ import api from '../api/client'
|
|||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import AgentPanel from '../components/AgentPanel'
|
import AgentPanel from '../components/AgentPanel'
|
||||||
import ProxmoxPanel from '../components/ProxmoxPanel'
|
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||||
import WindowsPanel from '../components/WindowsPanel'
|
import { Server, HardDrive, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||||
import { Server, HardDrive, Users, Activity, AlertTriangle, Monitor } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [agents, setAgents] = useState([])
|
const [agents, setAgents] = useState([])
|
||||||
@ -12,7 +11,7 @@ export default function Dashboard() {
|
|||||||
const [agentDetails, setAgentDetails] = useState({})
|
const [agentDetails, setAgentDetails] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedId, setSelectedId] = useState(null)
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
const [selectedType, setSelectedType] = useState(null) // 'device' | 'proxmox' | 'windows'
|
const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox'
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
Promise.all([api.getAgents(), api.getCustomers()])
|
Promise.all([api.getAgents(), api.getCustomers()])
|
||||||
@ -35,11 +34,6 @@ export default function Dashboard() {
|
|||||||
const d = agentDetails[agentId]
|
const d = agentDetails[agentId]
|
||||||
return d?.system_data?.proxmox?.available === true
|
return d?.system_data?.proxmox?.available === true
|
||||||
}
|
}
|
||||||
const getAgentType = (agent) => {
|
|
||||||
if (isProxmox(agent.id)) return 'proxmox'
|
|
||||||
if (agent.platform === 'windows') return 'windows'
|
|
||||||
return 'device'
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload()
|
reload()
|
||||||
@ -95,7 +89,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-800">
|
<div className="divide-y divide-gray-800">
|
||||||
{customer.agents.map((agent) => (
|
{customer.agents.map((agent) => (
|
||||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(getAgentType(agent)) }} 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>
|
||||||
@ -111,14 +105,14 @@ export default function Dashboard() {
|
|||||||
{agents
|
{agents
|
||||||
.filter((a) => !a.customer_id)
|
.filter((a) => !a.customer_id)
|
||||||
.map((agent) => (
|
.map((agent) => (
|
||||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(getAgentType(agent)) }} 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 Panels */}
|
{/* Detail Panel — Geraet-Detail */}
|
||||||
{selectedId && selectedType === 'proxmox' && (
|
{selectedId && selectedType === 'proxmox' && (
|
||||||
<ProxmoxPanel
|
<ProxmoxPanel
|
||||||
agentId={selectedId}
|
agentId={selectedId}
|
||||||
@ -127,15 +121,7 @@ export default function Dashboard() {
|
|||||||
onReload={reload}
|
onReload={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedId && selectedType === 'windows' && (
|
{selectedId && selectedType !== 'proxmox' && (
|
||||||
<WindowsPanel
|
|
||||||
agentId={selectedId}
|
|
||||||
customers={customers}
|
|
||||||
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
|
||||||
onReload={reload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedId && selectedType === 'device' && (
|
|
||||||
<AgentPanel
|
<AgentPanel
|
||||||
agentId={selectedId}
|
agentId={selectedId}
|
||||||
customers={customers}
|
customers={customers}
|
||||||
@ -175,12 +161,8 @@ function AgentRow({ agent, isPve, onClick, selected }) {
|
|||||||
<StatusBadge status={agent.status} size="dot" />
|
<StatusBadge status={agent.status} size="dot" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-white inline-flex items-center gap-2">
|
<div className="text-sm text-white inline-flex items-center gap-2">
|
||||||
<span className={`text-[10px] font-mono px-1 rounded ${
|
<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' ? 'bg-blue-900/50 text-blue-400' :
|
{agent.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||||
agent.platform === 'windows' ? 'bg-sky-900/50 text-sky-400' :
|
|
||||||
'bg-orange-900/50 text-orange-400'
|
|
||||||
}`}>
|
|
||||||
{agent.platform === 'linux' ? 'LNX' : agent.platform === 'windows' ? 'WIN' : 'OPN'}
|
|
||||||
</span>
|
</span>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,7 +16,6 @@ export default function Firmware() {
|
|||||||
const [firmwareData, setFirmwareData] = useState(null)
|
const [firmwareData, setFirmwareData] = useState(null)
|
||||||
const [installerData, setInstallerData] = useState(null)
|
const [installerData, setInstallerData] = useState(null)
|
||||||
const [agents, setAgents] = useState([])
|
const [agents, setAgents] = useState([])
|
||||||
const [customers, setCustomers] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [installerUploading, setInstallerUploading] = useState(false)
|
const [installerUploading, setInstallerUploading] = useState(false)
|
||||||
@ -31,16 +30,14 @@ export default function Firmware() {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [fw, ag, inst, cu] = await Promise.all([
|
const [fw, ag, inst] = await Promise.all([
|
||||||
api.getFirmwareInfo().catch(() => null),
|
api.getFirmwareInfo().catch(() => null),
|
||||||
api.getAgents().catch(() => []),
|
api.getAgents().catch(() => []),
|
||||||
api.getInstallerInfo().catch(() => null),
|
api.getInstallerInfo().catch(() => null),
|
||||||
api.getCustomers().catch(() => []),
|
|
||||||
])
|
])
|
||||||
setFirmwareData(fw)
|
setFirmwareData(fw)
|
||||||
setAgents(Array.isArray(ag) ? ag : [])
|
setAgents(Array.isArray(ag) ? ag : [])
|
||||||
setInstallerData(inst)
|
setInstallerData(inst)
|
||||||
setCustomers(Array.isArray(cu) ? cu : [])
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -231,9 +228,7 @@ export default function Firmware() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="w-4 h-4 text-orange-400" />
|
<Package className="w-4 h-4 text-orange-400" />
|
||||||
<span className="text-white text-sm font-medium">
|
<span className="text-white text-sm font-medium">{inst.platform === 'linux' ? 'Linux' : 'OPNsense / FreeBSD'}</span>
|
||||||
{inst.platform === 'linux' ? 'Linux' : inst.platform === 'windows' ? 'Windows' : 'OPNsense / FreeBSD'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{inst.filename} — {formatSize(inst.size)} — {new Date(inst.uploaded_at).toLocaleString('de-DE')}
|
{inst.filename} — {formatSize(inst.size)} — {new Date(inst.uploaded_at).toLocaleString('de-DE')}
|
||||||
@ -263,23 +258,6 @@ export default function Firmware() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{installerData?.installers?.some(i => i.platform === 'windows') && (
|
|
||||||
<div className="mb-4 bg-gray-950 rounded p-3 relative">
|
|
||||||
<div className="text-[10px] text-gray-500 mb-1">Installationsbefehl (Windows, als Admin in PowerShell):</div>
|
|
||||||
<pre className="text-xs font-mono text-sky-300 whitespace-pre-wrap">{`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; $wc=New-Object Net.WebClient; $wc.Headers["X-API-Key"]="YOUR_AGENT_KEY"; $wc.DownloadFile("${BACKEND_URL}/api/v1/installer/download?platform=windows","C:\\rmm-install.zip"); Expand-Archive -Path C:\\rmm-install.zip -DestinationPath C:\\rmm-install -Force; Set-ExecutionPolicy Bypass -Scope Process -Force; C:\\rmm-install\\install.ps1 -BackendURL "${BACKEND_URL}" -APIKey "YOUR_AGENT_KEY" -AgentName "PC-NAME"`}</pre>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
copyToClipboard(`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; $wc=New-Object Net.WebClient; $wc.Headers["X-API-Key"]="YOUR_AGENT_KEY"; $wc.DownloadFile("${BACKEND_URL}/api/v1/installer/download?platform=windows","C:\\rmm-install.zip"); Expand-Archive -Path C:\\rmm-install.zip -DestinationPath C:\\rmm-install -Force; Set-ExecutionPolicy Bypass -Scope Process -Force; C:\\rmm-install\\install.ps1 -BackendURL "${BACKEND_URL}" -APIKey "YOUR_AGENT_KEY" -AgentName "PC-NAME"`)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}}
|
|
||||||
className="absolute top-2 right-2 text-gray-600 hover:text-sky-400 transition-colors"
|
|
||||||
>
|
|
||||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload */}
|
{/* Upload */}
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
<select
|
<select
|
||||||
@ -289,7 +267,6 @@ export default function Firmware() {
|
|||||||
>
|
>
|
||||||
<option value="freebsd">OPNsense / FreeBSD</option>
|
<option value="freebsd">OPNsense / FreeBSD</option>
|
||||||
<option value="linux">Linux</option>
|
<option value="linux">Linux</option>
|
||||||
<option value="windows">Windows</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@ -322,16 +299,14 @@ export default function Firmware() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent List — nach Kunde gruppiert */}
|
{/* Agent List */}
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
||||||
<div className="px-4 py-2.5 border-b border-gray-800 flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Agents</h2>
|
||||||
Agents ({agents.length})
|
|
||||||
</span>
|
|
||||||
{hasAnyFirmware && (
|
{hasAnyFirmware && (
|
||||||
<button
|
<button
|
||||||
onClick={requestAll}
|
onClick={requestAll}
|
||||||
className="flex items-center gap-1.5 text-orange-400 hover:text-orange-300 text-xs transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<ArrowUpCircle className="w-3.5 h-3.5" />
|
<ArrowUpCircle className="w-3.5 h-3.5" />
|
||||||
Alle updaten
|
Alle updaten
|
||||||
@ -339,81 +314,66 @@ export default function Firmware() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
<div className="space-y-2">
|
||||||
// Gruppieren nach customer_id
|
{agents.map((agent) => {
|
||||||
const groups = {}
|
const outdated = needsUpdate(agent)
|
||||||
agents.forEach(a => {
|
const pending = agent.update_requested
|
||||||
const gid = a.customer_id ?? '__none__'
|
const agentPlatform = agent.platform || 'freebsd'
|
||||||
if (!groups[gid]) groups[gid] = []
|
const fw = getFwForAgent(agent)
|
||||||
groups[gid].push(a)
|
const platformLabel = agentPlatform === 'linux' ? 'Linux' : 'FreeBSD'
|
||||||
})
|
const platformColor = agentPlatform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'
|
||||||
const sorted = Object.entries(groups).sort(([a], [b]) => {
|
|
||||||
if (a === '__none__') return 1
|
|
||||||
if (b === '__none__') return -1
|
|
||||||
const ca = customers.find(c => c.id === parseInt(a))
|
|
||||||
const cb = customers.find(c => c.id === parseInt(b))
|
|
||||||
return (ca?.number || '').localeCompare(cb?.number || '')
|
|
||||||
})
|
|
||||||
return sorted.map(([gid, gAgents]) => {
|
|
||||||
const customer = gid !== '__none__' ? customers.find(c => c.id === parseInt(gid)) : null
|
|
||||||
return (
|
return (
|
||||||
<div key={gid}>
|
<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="px-4 py-1 bg-gray-800/40 border-b border-gray-800/60">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">
|
<div className="flex items-center gap-2">
|
||||||
{customer ? `${customer.number} — ${customer.name}` : 'Kein Kunde'}
|
<span className={`w-2 h-2 rounded-full shrink-0 ${
|
||||||
</span>
|
agent.status === 'online' ? 'bg-emerald-500' :
|
||||||
<span className="text-[10px] text-gray-700 ml-1.5">({gAgents.length})</span>
|
agent.status === 'stale' ? 'bg-yellow-500' : 'bg-gray-600'
|
||||||
|
}`} />
|
||||||
|
<span className="text-white text-sm font-medium truncate">{agent.name}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${platformColor}`}>{platformLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
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>
|
||||||
|
{pending && (
|
||||||
|
<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
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!outdated && fw && agent.agent_version && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||||
|
Aktuell
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||||
|
{pending ? (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelUpdate(agent.id, agent.name)}
|
||||||
|
className="flex items-center gap-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1.5 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
) : outdated ? (
|
||||||
|
<button
|
||||||
|
onClick={() => requestUpdate(agent.id, agent.name)}
|
||||||
|
className="flex items-center gap-1.5 bg-orange-600 hover:bg-orange-500 text-white px-3 py-1.5 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="w-3.5 h-3.5" />
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{gAgents.map(agent => {
|
|
||||||
const outdated = needsUpdate(agent)
|
|
||||||
const pending = agent.update_requested
|
|
||||||
const agentPlatform = agent.platform || 'freebsd'
|
|
||||||
const fw = getFwForAgent(agent)
|
|
||||||
const isLinux = agentPlatform === 'linux'
|
|
||||||
const isWindows = agentPlatform === 'windows'
|
|
||||||
const platformLabel = isWindows ? 'WIN' : isLinux ? 'LNX' : 'BSD'
|
|
||||||
const platformColor = isWindows
|
|
||||||
? 'text-sky-400 bg-sky-900/30'
|
|
||||||
: isLinux
|
|
||||||
? 'text-blue-400 bg-blue-900/30'
|
|
||||||
: 'text-orange-400 bg-orange-900/30'
|
|
||||||
return (
|
|
||||||
<div key={agent.id} className="px-4 py-1.5 flex items-center gap-2.5 hover:bg-gray-800/30 border-b border-gray-800/30 last:border-0">
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
||||||
agent.status === 'online' ? 'bg-green-400' :
|
|
||||||
agent.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
|
|
||||||
}`} />
|
|
||||||
<span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{agent.name}</span>
|
|
||||||
<span className={`text-[10px] px-1 py-0.5 rounded flex-shrink-0 ${platformColor}`}>
|
|
||||||
{platformLabel}
|
|
||||||
</span>
|
|
||||||
<span className={`text-[10px] font-mono flex-shrink-0 ${outdated ? 'text-yellow-400' : 'text-gray-600'}`}>
|
|
||||||
v{agent.agent_version || '—'}
|
|
||||||
{fw && outdated && <span className="text-gray-600"> → v{fw.version}</span>}
|
|
||||||
</span>
|
|
||||||
{pending && (
|
|
||||||
<span className="text-[10px] text-orange-400 flex-shrink-0">ausstehend</span>
|
|
||||||
)}
|
|
||||||
{pending ? (
|
|
||||||
<button onClick={() => cancelUpdate(agent.id, agent.name)}
|
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0" title="Abbrechen">
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
) : outdated ? (
|
|
||||||
<button onClick={() => requestUpdate(agent.id, agent.name)}
|
|
||||||
className="text-orange-400 hover:text-orange-300 transition-colors flex-shrink-0" title="Update anfordern">
|
|
||||||
<ArrowUpCircle className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
) : fw ? (
|
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-green-700 flex-shrink-0" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
})()}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -199,28 +199,18 @@ export default function SettingsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERM_LABELS = {
|
|
||||||
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50', desc: 'Nur WS-Connect + Heartbeat' },
|
|
||||||
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50', desc: 'GET-Endpoints, kein Schreiben' },
|
|
||||||
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50', desc: 'Lesen + Schreiben, kein Terminal' },
|
|
||||||
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50', desc: 'Voll (kein Terminal)' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function APIKeysSection() {
|
function APIKeysSection() {
|
||||||
const [keys, setKeys] = useState([])
|
const [keys, setKeys] = useState([])
|
||||||
const [customers, setCustomers] = useState([])
|
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [newPerms, setNewPerms] = useState('read')
|
|
||||||
const [newCustomer, setNewCustomer] = useState('')
|
|
||||||
const [newKey, setNewKey] = useState(null)
|
const [newKey, setNewKey] = useState(null)
|
||||||
const [copied, setCopied] = useState(null)
|
const [copied, setCopied] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Promise.all([api.getAPIKeys(), api.getCustomers()])
|
api.getAPIKeys()
|
||||||
.then(([k, c]) => { setKeys(k || []); setCustomers(c || []) })
|
.then(k => setKeys(k || []))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
useEffect(() => { reload() }, [])
|
useEffect(() => { reload() }, [])
|
||||||
@ -228,11 +218,9 @@ function APIKeysSection() {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newName.trim()) return
|
if (!newName.trim()) return
|
||||||
try {
|
try {
|
||||||
const result = await api.createAPIKey(newName.trim(), newPerms, newCustomer ? parseInt(newCustomer) : null)
|
const result = await api.createAPIKey(newName.trim())
|
||||||
setNewKey(result)
|
setNewKey(result)
|
||||||
setNewName('')
|
setNewName('')
|
||||||
setNewPerms('read')
|
|
||||||
setNewCustomer('')
|
|
||||||
reload()
|
reload()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fehler: ' + e.message)
|
alert('Fehler: ' + e.message)
|
||||||
@ -270,56 +258,25 @@ function APIKeysSection() {
|
|||||||
|
|
||||||
{/* Neuer Key Dialog */}
|
{/* Neuer Key Dialog */}
|
||||||
{showAdd && !newKey && (
|
{showAdd && !newKey && (
|
||||||
<div className="px-4 py-3 border-b border-gray-800 space-y-2">
|
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end">
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<label className="text-xs text-gray-500">Bezeichnung</label>
|
||||||
<label className="text-xs text-gray-500">Bezeichnung</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={newName}
|
||||||
value={newName}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
placeholder="z.B. Agent-Key Kunde XY"
|
||||||
placeholder="z.B. Agent-Key Kunde XY"
|
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
autoFocus
|
||||||
autoFocus
|
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-36">
|
|
||||||
<label className="text-xs text-gray-500">Berechtigung</label>
|
|
||||||
<select
|
|
||||||
value={newPerms}
|
|
||||||
onChange={(e) => setNewPerms(e.target.value)}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
|
||||||
>
|
|
||||||
<option value="agent">Agent (nur WS)</option>
|
|
||||||
<option value="read">Read-only</option>
|
|
||||||
<option value="write">Write</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="w-48">
|
|
||||||
<label className="text-xs text-gray-500">Kunde (optional)</label>
|
|
||||||
<select
|
|
||||||
value={newCustomer}
|
|
||||||
onChange={(e) => setNewCustomer(e.target.value)}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
|
|
||||||
>
|
|
||||||
<option value="">Alle Kunden</option>
|
|
||||||
{customers.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white self-end">
|
|
||||||
Erstellen
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 self-end">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{newPerms && PERM_LABELS[newPerms] && (
|
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
|
||||||
<div className="text-xs text-gray-500 pl-1">{PERM_LABELS[newPerms].desc}</div>
|
Erstellen
|
||||||
)}
|
</button>
|
||||||
|
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -347,86 +304,49 @@ function APIKeysSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Key Liste — gruppiert nach Kunde */}
|
{/* Key Liste */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-4 py-3 text-gray-500 text-xs">Laden...</div>
|
<div className="px-4 py-3 text-gray-500 text-sm">Laden...</div>
|
||||||
) : keys.length === 0 ? (
|
) : keys.length === 0 ? (
|
||||||
<div className="px-4 py-4 text-center text-gray-500 text-xs">Keine API-Keys vorhanden</div>
|
<div className="px-4 py-6 text-center text-gray-500 text-sm">Keine API-Keys vorhanden</div>
|
||||||
) : (() => {
|
) : (
|
||||||
// Gruppen bauen: null-Kunde zuerst, dann nach Kundennummer sortiert
|
<div className="divide-y divide-gray-800">
|
||||||
const groups = {}
|
{keys.map((k) => (
|
||||||
keys.forEach(k => {
|
<div key={k.id} className="px-4 py-2.5 flex items-center justify-between">
|
||||||
const gid = k.customer_id ?? '__global__'
|
<div className="min-w-0 flex-1">
|
||||||
if (!groups[gid]) groups[gid] = []
|
<div className="flex items-center gap-2">
|
||||||
groups[gid].push(k)
|
<span className="text-sm text-white">{k.name}</span>
|
||||||
})
|
</div>
|
||||||
const sorted = Object.entries(groups).sort(([a], [b]) => {
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
if (a === '__global__') return -1
|
<span className="text-xs text-gray-600 font-mono">{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}</span>
|
||||||
if (b === '__global__') return 1
|
<button
|
||||||
const ca = customers.find(c => c.id === parseInt(a))
|
onClick={() => copyText(k.key, k.id)}
|
||||||
const cb = customers.find(c => c.id === parseInt(b))
|
className="text-gray-600 hover:text-orange-400 transition-colors"
|
||||||
return (ca?.number || '').localeCompare(cb?.number || '')
|
title="Key kopieren"
|
||||||
})
|
>
|
||||||
return (
|
{copied === k.id ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
|
||||||
<div className="divide-y divide-gray-800">
|
</button>
|
||||||
{sorted.map(([gid, gkeys]) => {
|
<span className="text-xs text-gray-600">
|
||||||
const customer = gid !== '__global__' ? customers.find(c => c.id === parseInt(gid)) : null
|
Erstellt: {new Date(k.created_at).toLocaleDateString('de-DE')}
|
||||||
return (
|
</span>
|
||||||
<div key={gid}>
|
{k.last_used && (
|
||||||
{/* Gruppen-Header */}
|
<span className="text-xs text-gray-600">
|
||||||
<div className="px-4 py-1.5 bg-gray-800/40 flex items-center gap-2">
|
Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')}
|
||||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">
|
</span>
|
||||||
{customer ? `${customer.number} — ${customer.name}` : 'Global'}
|
)}
|
||||||
</span>
|
</div>
|
||||||
<span className="text-[10px] text-gray-700">({gkeys.length})</span>
|
</div>
|
||||||
</div>
|
<button
|
||||||
{/* Keys der Gruppe */}
|
onClick={() => handleDelete(k.id, k.name)}
|
||||||
{gkeys.map(k => {
|
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-800 transition-colors ml-2"
|
||||||
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
title="Key loeschen"
|
||||||
const lastUsedAgo = k.last_used
|
>
|
||||||
? (() => {
|
<Trash2 className="w-4 h-4" />
|
||||||
const s = (Date.now() - new Date(k.last_used)) / 1000
|
</button>
|
||||||
if (s < 120) return 'gerade eben'
|
|
||||||
if (s < 3600) return `${Math.floor(s/60)}m`
|
|
||||||
if (s < 86400) return `${Math.floor(s/3600)}h`
|
|
||||||
return `${Math.floor(s/86400)}d`
|
|
||||||
})()
|
|
||||||
: null
|
|
||||||
return (
|
|
||||||
<div key={k.id} className="px-4 py-1.5 flex items-center gap-2.5 hover:bg-gray-800/30 group">
|
|
||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
|
|
||||||
{perm.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-300 flex-1 min-w-0 truncate">{k.name}</span>
|
|
||||||
<code className="text-xs text-gray-600 font-mono flex-shrink-0">
|
|
||||||
{k.key.substring(0, 8)}…{k.key.substring(k.key.length - 4)}
|
|
||||||
</code>
|
|
||||||
{lastUsedAgo && (
|
|
||||||
<span className="text-[10px] text-gray-700 flex-shrink-0 hidden md:block"
|
|
||||||
title={`Zuletzt: ${new Date(k.last_used).toLocaleString('de-DE')}`}>
|
|
||||||
{lastUsedAgo}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button onClick={() => copyText(k.key, k.id)}
|
|
||||||
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0 opacity-0 group-hover:opacity-100"
|
|
||||||
title="Key kopieren">
|
|
||||||
{copied === k.id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleDelete(k.id, k.name)}
|
|
||||||
className="text-gray-700 hover:text-red-400 transition-colors flex-shrink-0 opacity-0 group-hover:opacity-100"
|
|
||||||
title="Key loeschen">
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})()
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -636,7 +556,7 @@ function SystemSettingsSection() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.backend_url_internal || ''}
|
value={form.backend_url_internal || ''}
|
||||||
onChange={(e) => setForm({ ...form, backend_url_internal: e.target.value })}
|
onChange={(e) => setForm({ ...form, backend_url_internal: e.target.value })}
|
||||||
placeholder="https://192.168.85.13:8443"
|
placeholder="https://<BACKEND-IP>:8443"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1"
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1"
|
||||||
/>
|
/>
|
||||||
<div className="text-[10px] text-gray-600 mt-0.5">Fuer Tunnel-Zugriff und lokale Verbindungen</div>
|
<div className="text-[10px] text-gray-600 mt-0.5">Fuer Tunnel-Zugriff und lokale Verbindungen</div>
|
||||||
|
|||||||
@ -1,321 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
|
||||||
import api from '../api/client'
|
|
||||||
import StatusBadge from '../components/StatusBadge'
|
|
||||||
import WindowsPanel from '../components/WindowsPanel'
|
|
||||||
import { Search, ChevronUp, ChevronDown, Settings2, X } from 'lucide-react'
|
|
||||||
|
|
||||||
const ALL_COLUMNS = [
|
|
||||||
{ id: 'customer', label: 'Kunde', default: true },
|
|
||||||
{ id: 'name', label: 'Name', default: true, locked: true },
|
|
||||||
{ id: 'hostname', label: 'Hostname', default: false },
|
|
||||||
{ id: 'os', label: 'OS', default: true },
|
|
||||||
{ id: 'version', label: 'Agent Version', default: true },
|
|
||||||
{ id: 'ip', label: 'IP', default: true },
|
|
||||||
{ id: 'uptime', label: 'Uptime', default: true },
|
|
||||||
{ id: 'cpu', label: 'CPU', default: true },
|
|
||||||
{ id: 'ram', label: 'RAM', default: true },
|
|
||||||
{ id: 'disk', label: 'Disk (C:)', default: true },
|
|
||||||
{ id: 'lastresponse', label: 'Letzter Kontakt', default: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'rmm-windows-columns'
|
|
||||||
|
|
||||||
function loadColumns() {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (saved) return JSON.parse(saved)
|
|
||||||
} catch {}
|
|
||||||
return ALL_COLUMNS.filter(c => c.default).map(c => c.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveColumns(cols) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cols))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUptime(seconds) {
|
|
||||||
if (!seconds) return '—'
|
|
||||||
const d = Math.floor(seconds / 86400)
|
|
||||||
const h = Math.floor((seconds % 86400) / 3600)
|
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
|
||||||
if (d > 0) return `${d}d ${h}h`
|
|
||||||
return `${h}h ${m}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Windows() {
|
|
||||||
const [agents, setAgents] = useState([])
|
|
||||||
const [customers, setCustomers] = useState([])
|
|
||||||
const [agentDetails, setAgentDetails] = useState({})
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [sortKey, setSortKey] = useState('customer')
|
|
||||||
const [sortDir, setSortDir] = useState('asc')
|
|
||||||
const [selectedId, setSelectedId] = useState(null)
|
|
||||||
const [visibleCols, setVisibleCols] = useState(loadColumns)
|
|
||||||
const [showColMenu, setShowColMenu] = useState(false)
|
|
||||||
const colMenuRef = useRef(null)
|
|
||||||
|
|
||||||
const reload = () => {
|
|
||||||
Promise.all([api.getAgents(), api.getCustomers()])
|
|
||||||
.then(([a, c]) => {
|
|
||||||
setAgents((a || []).filter(ag => ag.platform === 'windows'))
|
|
||||||
setCustomers(c || [])
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reload()
|
|
||||||
const iv = setInterval(reload, 30000)
|
|
||||||
return () => clearInterval(iv)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
agents.forEach(a => {
|
|
||||||
if (!agentDetails[a.id]) {
|
|
||||||
api.getAgent(a.id).then(d =>
|
|
||||||
setAgentDetails(prev => ({ ...prev, [a.id]: d }))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [agents])
|
|
||||||
|
|
||||||
// Close column menu on click outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e) => {
|
|
||||||
if (colMenuRef.current && !colMenuRef.current.contains(e.target)) setShowColMenu(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handler)
|
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const customerMap = useMemo(() =>
|
|
||||||
Object.fromEntries((customers || []).map(c => [c.id, c])), [customers])
|
|
||||||
|
|
||||||
const toggleSort = (key) => {
|
|
||||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
|
||||||
else { setSortKey(key); setSortDir('asc') }
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleCol = (id) => {
|
|
||||||
const col = ALL_COLUMNS.find(c => c.id === id)
|
|
||||||
if (col?.locked) return
|
|
||||||
const next = visibleCols.includes(id)
|
|
||||||
? visibleCols.filter(c => c !== id)
|
|
||||||
: [...visibleCols, id]
|
|
||||||
setVisibleCols(next)
|
|
||||||
saveColumns(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isColVisible = (id) => visibleCols.includes(id)
|
|
||||||
|
|
||||||
const enriched = useMemo(() => agents.map(a => {
|
|
||||||
const d = agentDetails[a.id]
|
|
||||||
const win = d?.system_data?.windows
|
|
||||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
|
||||||
|
|
||||||
const memPct = win?.memory?.total_bytes > 0
|
|
||||||
? ((win.memory.total_bytes - (win.memory.available_bytes ?? 0)) / win.memory.total_bytes * 100)
|
|
||||||
: null
|
|
||||||
|
|
||||||
// Ersten FIXED-Disk nehmen (meistens C:)
|
|
||||||
const disk = win?.disks?.[0]
|
|
||||||
const diskPct = disk?.used_percent ?? null
|
|
||||||
|
|
||||||
return {
|
|
||||||
...a,
|
|
||||||
customer: cust,
|
|
||||||
customerName: cust?.name || '',
|
|
||||||
customerNumber: cust?.number || '',
|
|
||||||
os: win?.os_version || '—',
|
|
||||||
cpuPct: win?.cpu?.load_percent ?? null,
|
|
||||||
memPct,
|
|
||||||
diskPct,
|
|
||||||
uptime: win?.uptime_seconds || 0,
|
|
||||||
}
|
|
||||||
}), [agents, agentDetails, customerMap])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let list = enriched
|
|
||||||
if (search) {
|
|
||||||
const q = search.toLowerCase()
|
|
||||||
list = list.filter(a =>
|
|
||||||
a.name.toLowerCase().includes(q) ||
|
|
||||||
(a.ip || '').toLowerCase().includes(q) ||
|
|
||||||
(a.hostname || '').toLowerCase().includes(q) ||
|
|
||||||
a.customerName.toLowerCase().includes(q) ||
|
|
||||||
a.customerNumber.toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
list = [...list].sort((a, b) => {
|
|
||||||
let va, vb
|
|
||||||
switch (sortKey) {
|
|
||||||
case 'customer': va = a.customerNumber; vb = b.customerNumber; break
|
|
||||||
case 'name': va = a.name; vb = b.name; break
|
|
||||||
case 'os': va = a.os; vb = b.os; break
|
|
||||||
case 'version': va = a.agent_version || ''; vb = b.agent_version || ''; break
|
|
||||||
case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime
|
|
||||||
case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1)
|
|
||||||
case 'ram': return sortDir === 'asc' ? (a.memPct ?? 999) - (b.memPct ?? 999) : (b.memPct ?? -1) - (a.memPct ?? -1)
|
|
||||||
case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1)
|
|
||||||
case 'status': va = a.status; vb = b.status; break
|
|
||||||
default: va = a.name; vb = b.name
|
|
||||||
}
|
|
||||||
if (typeof va === 'string') {
|
|
||||||
const cmp = va.localeCompare(vb)
|
|
||||||
return sortDir === 'asc' ? cmp : -cmp
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
return list
|
|
||||||
}, [enriched, search, sortKey, sortDir])
|
|
||||||
|
|
||||||
const SortHeader = ({ label, k, className = '' }) => (
|
|
||||||
<th
|
|
||||||
className={`px-3 py-2 font-medium cursor-pointer hover:text-gray-300 select-none ${className}`}
|
|
||||||
onClick={() => toggleSort(k)}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
{label}
|
|
||||||
{sortKey === k && (sortDir === 'asc'
|
|
||||||
? <ChevronUp className="w-3 h-3" />
|
|
||||||
: <ChevronDown className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-bold text-white">Windows Agents ({agents.length})</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Suchen (Name, IP, Kunde)..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-sky-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Spalten-Toggle */}
|
|
||||||
<div className="relative" ref={colMenuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowColMenu(!showColMenu)}
|
|
||||||
className="p-2 bg-gray-900 border border-gray-800 rounded hover:border-gray-600 text-gray-400 hover:text-white transition-colors"
|
|
||||||
title="Spalten konfigurieren"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{showColMenu && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 bg-gray-900 border border-gray-700 rounded-lg shadow-xl py-2 w-52">
|
|
||||||
<div className="px-3 py-1 text-xs text-gray-500 font-medium">Sichtbare Spalten</div>
|
|
||||||
{ALL_COLUMNS.map(col => (
|
|
||||||
<label
|
|
||||||
key={col.id}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-800 ${col.locked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={visibleCols.includes(col.id)}
|
|
||||||
onChange={() => toggleCol(col.id)}
|
|
||||||
disabled={col.locked}
|
|
||||||
className="rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500 focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-300">{col.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-gray-500">Laden...</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-x-auto">
|
|
||||||
<table className="w-full text-xs whitespace-nowrap">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
|
||||||
<th className="px-2 py-1.5 font-medium w-8"></th>
|
|
||||||
{isColVisible('customer') && <SortHeader label="KUNDE" k="customer" />}
|
|
||||||
{isColVisible('name') && <SortHeader label="NAME" k="name" />}
|
|
||||||
{isColVisible('hostname') && <th className="px-3 py-2 font-medium">HOSTNAME</th>}
|
|
||||||
{isColVisible('os') && <SortHeader label="OS" k="os" />}
|
|
||||||
{isColVisible('version') && <SortHeader label="AGENT" k="version" />}
|
|
||||||
{isColVisible('ip') && <th className="px-3 py-2 font-medium">IP</th>}
|
|
||||||
{isColVisible('uptime') && <SortHeader label="UPTIME" k="uptime" />}
|
|
||||||
{isColVisible('cpu') && <SortHeader label="CPU" k="cpu" />}
|
|
||||||
{isColVisible('ram') && <SortHeader label="RAM" k="ram" />}
|
|
||||||
{isColVisible('disk') && <SortHeader label="DISK" k="disk" />}
|
|
||||||
{isColVisible('lastresponse') && <th className="px-3 py-2 font-medium">LETZTER KONTAKT</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-800">
|
|
||||||
{filtered.map(a => (
|
|
||||||
<tr
|
|
||||||
key={a.id}
|
|
||||||
onClick={() => setSelectedId(a.id)}
|
|
||||||
className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`}
|
|
||||||
>
|
|
||||||
<td className="px-2 py-1.5"><StatusBadge status={a.status} size="dot" /></td>
|
|
||||||
{isColVisible('customer') && (
|
|
||||||
<td className="px-3 py-1.5 text-gray-400">
|
|
||||||
{a.customer
|
|
||||||
? <span className="text-sky-400">{a.customerNumber}</span>
|
|
||||||
: <span className="text-gray-600">—</span>}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{isColVisible('name') && <td className="px-3 py-1.5 text-white font-medium">{a.name}</td>}
|
|
||||||
{isColVisible('hostname') && <td className="px-3 py-1.5 text-gray-400">{a.hostname || '—'}</td>}
|
|
||||||
{isColVisible('os') && <td className="px-3 py-1.5 text-gray-400">{a.os}</td>}
|
|
||||||
{isColVisible('version') && <td className="px-3 py-1.5 text-gray-500">{a.agent_version ? `v${a.agent_version}` : '—'}</td>}
|
|
||||||
{isColVisible('ip') && <td className="px-3 py-1.5 text-gray-400">{a.ip || '—'}</td>}
|
|
||||||
{isColVisible('uptime') && <td className="px-3 py-1.5 text-gray-400">{formatUptime(a.uptime)}</td>}
|
|
||||||
{isColVisible('cpu') && <td className="px-3 py-1.5"><MiniBar value={a.cpuPct} /></td>}
|
|
||||||
{isColVisible('ram') && <td className="px-3 py-1.5"><MiniBar value={a.memPct} /></td>}
|
|
||||||
{isColVisible('disk') && <td className="px-3 py-1.5"><MiniBar value={a.diskPct} /></td>}
|
|
||||||
{isColVisible('lastresponse') && (
|
|
||||||
<td className="px-3 py-1.5 text-gray-500">
|
|
||||||
{a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
{search ? 'Keine Treffer' : 'Keine Windows Agents registriert'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedId && (
|
|
||||||
<WindowsPanel
|
|
||||||
agentId={selectedId}
|
|
||||||
customers={customers}
|
|
||||||
onClose={() => setSelectedId(null)}
|
|
||||||
onReload={reload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniBar({ value }) {
|
|
||||||
if (value === null || value === undefined) return <span className="text-gray-600 text-[10px]">—</span>
|
|
||||||
const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500'
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-10 h-1 bg-gray-800 rounded overflow-hidden">
|
|
||||||
<div className={`h-full rounded ${color}`} style={{ width: `${Math.min(value, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-gray-400">{value.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,23 +1,16 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig({
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
plugins: [react(), tailwindcss()],
|
||||||
const backendHost = env.VITE_BACKEND_HOST || 'localhost'
|
server: {
|
||||||
const backendPort = env.VITE_BACKEND_PORT || '8443'
|
proxy: {
|
||||||
const backendUrl = `https://${backendHost}:${backendPort}`
|
'/api': {
|
||||||
|
target: 'https://your-backend:8443', // TODO: set your backend URL
|
||||||
return {
|
changeOrigin: true,
|
||||||
plugins: [react(), tailwindcss()],
|
secure: false,
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: backendUrl,
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user