diff --git a/README.md b/README.md
index 04b1611..10a6de9 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,9 @@ Client ──► Backend:ProxyPort ──► WebSocket (Binary) ──► Agent
| `update` | `POST /agents/{id}/update` | Normales Update (Core + Packages, opt. Reboot) |
| `major_update` | `POST /agents/{id}/major-update` | Major-Upgrade (2-Phasen: Base/Kernel → Reboot → Packages) |
| `reboot` | `POST /agents/{id}/reboot` | Reboot mit konfigurierbarer Verzoegerung |
+| `wg_add_peer` | `POST /agents/{id}/wireguard/peers` | WireGuard-Peer anlegen (Keypair + Config) |
+| `wg_list_peers` | `GET /agents/{id}/wireguard/peers` | Alle Peers einer WG-Instanz auflisten |
+| `wg_delete_peer` | `DELETE /agents/{id}/wireguard/peers/{uuid}` | WireGuard-Peer loeschen |
| `tunnel_connect` | `POST /agents/{id}/tunnel` | TCP-Tunnel zu lokalem Dienst oeffnen |
| `tunnel_disconnect` | `DELETE /agents/{id}/tunnel/{tid}` | Tunnel schliessen |
@@ -262,6 +265,60 @@ curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \
-d '{"command":"uptime","timeout":30}'
```
+### WireGuard-Peer Management
+
+| Methode | Endpoint | Beschreibung |
+|---------|----------|-------------|
+| POST | `/api/v1/agents/{id}/wireguard/peers` | Neuen Peer anlegen |
+| GET | `/api/v1/agents/{id}/wireguard/peers` | Alle Peers auflisten (`?server_name=X`) |
+| DELETE | `/api/v1/agents/{id}/wireguard/peers/{uuid}` | Peer loeschen |
+
+**Peer anlegen — Parameter:**
+
+| Parameter | Pflicht | Default | Beschreibung |
+|-----------|---------|---------|-------------|
+| `name` | Ja | — | Peer-Name (z.B. "VPN_MUELLER") |
+| `allowed_ips` | Nein | `0.0.0.0/0` | AllowedIPs fuer Client-Config |
+| `dns` | Nein | — | DNS-Server fuer Client-Config |
+| `server_name` | Nein | `ALWAYSON_VPN` | Name der WG-Server-Instanz |
+| `endpoint` | Nein | WAN-IP:Port | Oeffentlicher Endpoint (Auto-Detect ueber Default-Route) |
+
+**Ablauf beim Anlegen:**
+1. Keypair wird auf der Firewall generiert (`wg genkey`/`wg pubkey`)
+2. Naechste freie IP aus dem Tunnel-Subnetz wird automatisch vergeben
+3. Peer wird in `config.xml` eingetragen und dem Server zugewiesen
+4. `configctl wireguard configure` laedt die Aenderung
+5. Response enthaelt die komplette Client-Config (ready for import)
+
+**Beispiel:**
+
+```bash
+# Peer anlegen
+curl -sk -H "X-API-Key: API_KEY" -X POST \
+ https://BACKEND/api/v1/agents/AGENT_ID/wireguard/peers \
+ -d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}'
+
+# Response enthaelt client_config:
+# [Interface]
+# PrivateKey = ...
+# Address = 10.33.144.2/32
+# DNS = 10.172.100.210
+#
+# [Peer]
+# PublicKey = ...
+# AllowedIPs = 0.0.0.0/0
+# Endpoint = 79.238.149.26:8080
+# PersistentKeepalive = 25
+
+# Peers auflisten
+curl -sk -H "X-API-Key: API_KEY" \
+ https://BACKEND/api/v1/agents/AGENT_ID/wireguard/peers
+
+# Peer loeschen
+curl -sk -H "X-API-Key: API_KEY" -X DELETE \
+ https://BACKEND/api/v1/agents/AGENT_ID/wireguard/peers/PEER_UUID
+```
+
### Backup-Beispiele
```bash
@@ -389,7 +446,8 @@ rmm/
│ │ └── updates.go # Pending Updates (core + packages)
│ └── ws/
│ ├── client.go # WebSocket Client, Reconnect, Ping/Pong
-│ ├── handler.go # Command Handler (exec, tunnel_connect/disconnect)
+│ ├── handler.go # Command Handler (exec, backup, tunnel_connect/disconnect)
+│ ├── wireguard.go # WireGuard Peer Management (add/delete/list)
│ └── tunnel.go # Session-basierter Tunnel Manager
├── backend/
│ ├── main.go # Entry, TLS, HTTP Server
@@ -404,6 +462,7 @@ rmm/
│ │ ├── handlers.go # REST API Handler
│ │ ├── tunnel_proxy.go # Tunnel + Exec + Update REST Endpoints
│ │ ├── backup.go # Config-Backup Endpoints (trigger, list, download, diff)
+│ │ ├── wireguard.go # WireGuard Peer API Endpoints
│ │ └── middleware.go # API-Key Auth, Logging
│ └── ws/
│ ├── handler.go # WebSocket Upgrade, Connection R/W Pumps
diff --git a/agent/ws/handler.go b/agent/ws/handler.go
index 321b1f5..23cc0ef 100644
--- a/agent/ws/handler.go
+++ b/agent/ws/handler.go
@@ -42,6 +42,12 @@ func (h *Handler) HandleCommand(msg Message) {
response = h.handleReboot(msg)
case "backup":
response = h.handleBackup(msg)
+ case "wg_add_peer":
+ response = h.handleWGAddPeer(msg)
+ case "wg_delete_peer":
+ response = h.handleWGDeletePeer(msg)
+ case "wg_list_peers":
+ response = h.handleWGListPeers(msg)
case "tunnel_connect":
response = h.handleTunnelConnect(msg)
case "tunnel_disconnect":
diff --git a/agent/ws/wireguard.go b/agent/ws/wireguard.go
new file mode 100644
index 0000000..7b89c5a
--- /dev/null
+++ b/agent/ws/wireguard.go
@@ -0,0 +1,604 @@
+package ws
+
+import (
+ "encoding/xml"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "strings"
+)
+
+// --- OPNsense config.xml Structs ---
+
+type OPNsenseConfig struct {
+ XMLName xml.Name `xml:"opnsense"`
+ WireGuard *WGConfig `xml:"OPNsense>wireguard"`
+ Rest []byte `xml:",innerxml"`
+}
+
+type WGConfig struct {
+ General WGGeneral `xml:"general"`
+ Server WGServer `xml:"server"`
+ Client WGClient `xml:"client"`
+}
+
+type WGGeneral struct {
+ XMLName xml.Name `xml:"general"`
+ Version string `xml:"version,attr"`
+ PersistedAt string `xml:"persisted_at,attr"`
+ Description string `xml:"description,attr"`
+ Enabled string `xml:"enabled"`
+}
+
+type WGServer struct {
+ XMLName xml.Name `xml:"server"`
+ Version string `xml:"version,attr"`
+ PersistedAt string `xml:"persisted_at,attr"`
+ Description string `xml:"description,attr"`
+ Servers []WGServerInst `xml:"servers>server"`
+}
+
+type WGServerInst struct {
+ UUID string `xml:"uuid,attr"`
+ Enabled string `xml:"enabled"`
+ Name string `xml:"name"`
+ Instance string `xml:"instance"`
+ PubKey string `xml:"pubkey"`
+ PrivKey string `xml:"privkey"`
+ Port string `xml:"port"`
+ MTU string `xml:"mtu"`
+ DNS string `xml:"dns"`
+ TunnelAddress string `xml:"tunneladdress"`
+ DisableRoutes string `xml:"disableroutes"`
+ Gateway string `xml:"gateway"`
+ CarpDependOn string `xml:"carp_depend_on"`
+ Peers string `xml:"peers"`
+ Debug string `xml:"debug"`
+ Endpoint string `xml:"endpoint"`
+ PeerDNS string `xml:"peer_dns"`
+}
+
+type WGClient struct {
+ XMLName xml.Name `xml:"client"`
+ Version string `xml:"version,attr"`
+ PersistedAt string `xml:"persisted_at,attr"`
+ Description string `xml:"description,attr"`
+ Clients []WGClientInst `xml:"clients>client"`
+}
+
+type WGClientInst struct {
+ UUID string `xml:"uuid,attr"`
+ Enabled string `xml:"enabled"`
+ Name string `xml:"name"`
+ PubKey string `xml:"pubkey"`
+ PSK string `xml:"psk"`
+ TunnelAddress string `xml:"tunneladdress"`
+ ServerAddress string `xml:"serveraddress"`
+ ServerPort string `xml:"serverport"`
+ KeepAlive string `xml:"keepalive"`
+}
+
+// handleWGAddPeer erzeugt einen neuen WireGuard-Peer
+func (h *Handler) handleWGAddPeer(msg Message) Message {
+ response := Message{Type: "response", ID: msg.ID}
+
+ name, _ := msg.Params["name"].(string)
+ allowedIPs, _ := msg.Params["allowed_ips"].(string)
+ if name == "" {
+ response.Status = "error"
+ response.Error = "Parameter 'name' erforderlich"
+ return response
+ }
+ if allowedIPs == "" {
+ allowedIPs = "0.0.0.0/0"
+ }
+
+ // Optional: DNS fuer Client-Config
+ dns, _ := msg.Params["dns"].(string)
+
+ // Server-Name (default: ALWAYSON_VPN)
+ serverName := "ALWAYSON_VPN"
+ if sn, ok := msg.Params["server_name"].(string); ok && sn != "" {
+ serverName = sn
+ }
+
+ log.Printf("WG Add Peer: name=%s, server=%s, allowed_ips=%s", name, serverName, allowedIPs)
+
+ // 1. config.xml lesen
+ configData, err := os.ReadFile("/conf/config.xml")
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("config.xml lesen: %v", err)
+ return response
+ }
+
+ // 2. Server-Instanz finden (direkt im XML-Text arbeiten statt Unmarshal,
+ // um die gesamte config.xml nicht zu zerstoeren)
+ // Wir parsen nur den WireGuard-Teil, den Rest lassen wir in Ruhe.
+
+ // Server-Instanz per configctl/PHP auslesen und bearbeiten
+ // Sicherer Weg: OPNsense PHP-API nutzen
+ serverUUID, serverPubKey, serverPort, tunnelSubnet, err := h.findWGServer(string(configData), serverName)
+ if err != nil {
+ response.Status = "error"
+ response.Error = err.Error()
+ return response
+ }
+
+ // 3. Naechste freie IP im Subnetz bestimmen
+ usedIPs := h.getUsedPeerIPs(string(configData), serverUUID, tunnelSubnet)
+ nextIP, err := h.nextFreeIP(tunnelSubnet, usedIPs)
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("Keine freie IP: %v", err)
+ return response
+ }
+
+ // 4. Keypair generieren
+ privKeyOutput, err := h.runCommand("/usr/bin/wg genkey", 5)
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("wg genkey fehlgeschlagen: %v", err)
+ return response
+ }
+ clientPrivKey := strings.TrimSpace(privKeyOutput)
+
+ pubKeyOutput, err := h.runCommand(fmt.Sprintf("echo '%s' | /usr/bin/wg pubkey", clientPrivKey), 5)
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("wg pubkey fehlgeschlagen: %v", err)
+ return response
+ }
+ clientPubKey := strings.TrimSpace(pubKeyOutput)
+
+ // 5. UUID fuer neuen Peer generieren
+ uuidOutput, err := h.runCommand("/bin/uuidgen", 5)
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("uuidgen fehlgeschlagen: %v", err)
+ return response
+ }
+ peerUUID := strings.ToLower(strings.TrimSpace(uuidOutput))
+
+ // 6. Peer in config.xml eintragen (direkt per sed — zuverlaessiger als PHP parse_config)
+ err = h.addPeerViaSed(peerUUID, name, clientPubKey, nextIP, serverUUID)
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("Peer eintragen fehlgeschlagen: %v", err)
+ return response
+ }
+
+ // 7. WireGuard neu laden
+ reloadOutput, reloadErr := h.runCommand("/usr/local/sbin/configctl wireguard configure", 15)
+ if reloadErr != nil {
+ log.Printf("WireGuard reload Warnung: %v (%s)", reloadErr, reloadOutput)
+ }
+
+ // 8. Endpoint ermitteln
+ // Prioritaet: 1. Parameter "endpoint" 2. Default-Gateway-Interface IP 3. hostname
+ port := serverPort
+ if port == "" {
+ port = "51820"
+ }
+
+ endpoint := ""
+ if ep, ok := msg.Params["endpoint"].(string); ok && ep != "" {
+ // Wenn nur Host/IP ohne Port angegeben
+ if !strings.Contains(ep, ":") {
+ endpoint = fmt.Sprintf("%s:%s", ep, port)
+ } else {
+ endpoint = ep
+ }
+ } else {
+ // WAN-IP ueber Default-Route Interface ermitteln
+ routeOutput, _ := h.runCommand("/sbin/route -n get default 2>/dev/null | /usr/bin/grep interface | /usr/bin/awk '{print $2}'", 5)
+ wanIface := strings.TrimSpace(routeOutput)
+ if wanIface != "" {
+ ipOutput, _ := h.runCommand(fmt.Sprintf("/sbin/ifconfig %s 2>/dev/null | /usr/bin/grep 'inet ' | /usr/bin/awk '{print $2}' | /usr/bin/head -1", wanIface), 5)
+ wanIP := strings.TrimSpace(ipOutput)
+ if wanIP != "" {
+ endpoint = fmt.Sprintf("%s:%s", wanIP, port)
+ }
+ }
+ if endpoint == "" {
+ hostnameOutput, _ := h.runCommand("/bin/hostname", 5)
+ endpoint = fmt.Sprintf("%s:%s", strings.TrimSpace(hostnameOutput), port)
+ }
+ }
+
+ // 9. Client-Config zusammenbauen
+ clientConfig := fmt.Sprintf(`[Interface]
+PrivateKey = %s
+Address = %s/32
+`, clientPrivKey, nextIP)
+
+ if dns != "" {
+ clientConfig += fmt.Sprintf("DNS = %s\n", dns)
+ }
+
+ clientConfig += fmt.Sprintf(`
+[Peer]
+PublicKey = %s
+AllowedIPs = %s
+Endpoint = %s
+PersistentKeepalive = 25
+`, serverPubKey, allowedIPs, endpoint)
+
+ response.Status = "ok"
+ response.Data = map[string]interface{}{
+ "peer_uuid": peerUUID,
+ "name": name,
+ "client_ip": nextIP,
+ "public_key": clientPubKey,
+ "server_pubkey": serverPubKey,
+ "endpoint": endpoint,
+ "allowed_ips": allowedIPs,
+ "client_config": clientConfig,
+ "server_name": serverName,
+ "server_uuid": serverUUID,
+ }
+
+ log.Printf("WG Peer erstellt: %s (%s) -> %s", name, nextIP, serverName)
+ return response
+}
+
+// handleWGDeletePeer entfernt einen WireGuard-Peer
+func (h *Handler) handleWGDeletePeer(msg Message) Message {
+ response := Message{Type: "response", ID: msg.ID}
+
+ peerUUID, _ := msg.Params["peer_uuid"].(string)
+ if peerUUID == "" {
+ response.Status = "error"
+ response.Error = "Parameter 'peer_uuid' erforderlich"
+ return response
+ }
+
+ log.Printf("WG Delete Peer: uuid=%s", peerUUID)
+
+ // Peer per sed entfernen
+ if err := h.removePeerViaSed(peerUUID); err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("Peer loeschen fehlgeschlagen: %v", err)
+ return response
+ }
+
+ // WireGuard reload
+ h.runCommand("/usr/local/sbin/configctl wireguard configure", 15)
+
+ response.Status = "ok"
+ response.Data = map[string]interface{}{
+ "peer_uuid": peerUUID,
+ "message": "Peer entfernt",
+ }
+
+ log.Printf("WG Peer geloescht: %s", peerUUID)
+ return response
+}
+
+// handleWGListPeers listet alle Peers einer WG-Instanz
+func (h *Handler) handleWGListPeers(msg Message) Message {
+ response := Message{Type: "response", ID: msg.ID}
+
+ serverName := "ALWAYSON_VPN"
+ if sn, ok := msg.Params["server_name"].(string); ok && sn != "" {
+ serverName = sn
+ }
+
+ configData, err := os.ReadFile("/conf/config.xml")
+ if err != nil {
+ response.Status = "error"
+ response.Error = fmt.Sprintf("config.xml lesen: %v", err)
+ return response
+ }
+
+ configStr := string(configData)
+ serverUUID, _, _, tunnelSubnet, err := h.findWGServer(configStr, serverName)
+ if err != nil {
+ response.Status = "error"
+ response.Error = err.Error()
+ return response
+ }
+
+ // Peer-UUIDs aus Server lesen
+ peerUUIDs := h.getServerPeerUUIDs(configStr, serverUUID)
+
+ // Peer-Details sammeln
+ var peers []map[string]interface{}
+ for _, uuid := range peerUUIDs {
+ peer := h.getPeerByUUID(configStr, uuid)
+ if peer != nil {
+ peers = append(peers, peer)
+ }
+ }
+
+ if peers == nil {
+ peers = []map[string]interface{}{}
+ }
+
+ // Live WG-Status (Handshake, Transfer)
+ wgStatus, _ := h.runCommand("/usr/bin/wg show all", 5)
+
+ response.Status = "ok"
+ response.Data = map[string]interface{}{
+ "server_name": serverName,
+ "server_uuid": serverUUID,
+ "subnet": tunnelSubnet,
+ "peer_count": len(peers),
+ "peers": peers,
+ "wg_status": strings.TrimSpace(wgStatus),
+ }
+
+ return response
+}
+
+// --- Helper Functions ---
+
+// findWGServer sucht eine WG-Server-Instanz nach Name in der config.xml
+func (h *Handler) findWGServer(configXML, serverName string) (uuid, pubkey, port, tunnelAddr string, err error) {
+ type serverEntry struct {
+ UUID string `xml:"uuid,attr"`
+ Name string `xml:"name"`
+ PubKey string `xml:"pubkey"`
+ Port string `xml:"port"`
+ TunnelAddress string `xml:"tunneladdress"`
+ Peers string `xml:"peers"`
+ }
+
+ // Einfaches XML-Parsing nur fuer den WG-Teil
+ type opnsenseWG struct {
+ Servers []serverEntry `xml:"OPNsense>wireguard>server>servers>server"`
+ }
+
+ var cfg opnsenseWG
+ if xmlErr := xml.Unmarshal([]byte(configXML), &cfg); xmlErr != nil {
+ return "", "", "", "", fmt.Errorf("XML parsen: %v", xmlErr)
+ }
+
+ for _, srv := range cfg.Servers {
+ if srv.Name == serverName {
+ return srv.UUID, srv.PubKey, srv.Port, srv.TunnelAddress, nil
+ }
+ }
+
+ return "", "", "", "", fmt.Errorf("WireGuard-Server '%s' nicht gefunden", serverName)
+}
+
+// getUsedPeerIPs sammelt alle vergebenen IPs im Server-Subnetz
+func (h *Handler) getUsedPeerIPs(configXML, serverUUID, tunnelAddr string) []string {
+ type clientEntry struct {
+ UUID string `xml:"uuid,attr"`
+ TunnelAddress string `xml:"tunneladdress"`
+ }
+ type opnsenseClients struct {
+ Clients []clientEntry `xml:"OPNsense>wireguard>client>clients>client"`
+ }
+
+ var cfg opnsenseClients
+ xml.Unmarshal([]byte(configXML), &cfg)
+
+ peerUUIDs := h.getServerPeerUUIDs(configXML, serverUUID)
+ peerSet := make(map[string]bool)
+ for _, u := range peerUUIDs {
+ peerSet[u] = true
+ }
+
+ var ips []string
+ for _, c := range cfg.Clients {
+ if peerSet[c.UUID] {
+ // tunneladdress kann "10.33.144.2/32" sein
+ addr := strings.Split(c.TunnelAddress, "/")[0]
+ if addr != "" {
+ ips = append(ips, addr)
+ }
+ }
+ }
+
+ return ips
+}
+
+// getServerPeerUUIDs liest die Peer-UUIDs aus dem Server-Eintrag
+func (h *Handler) getServerPeerUUIDs(configXML, serverUUID string) []string {
+ type serverEntry struct {
+ UUID string `xml:"uuid,attr"`
+ Peers string `xml:"peers"`
+ }
+ type opnsenseServers struct {
+ Servers []serverEntry `xml:"OPNsense>wireguard>server>servers>server"`
+ }
+
+ var cfg opnsenseServers
+ xml.Unmarshal([]byte(configXML), &cfg)
+
+ for _, srv := range cfg.Servers {
+ if srv.UUID == serverUUID {
+ var uuids []string
+ for _, p := range strings.Split(srv.Peers, ",") {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ uuids = append(uuids, p)
+ }
+ }
+ return uuids
+ }
+ }
+ return nil
+}
+
+// getPeerByUUID liest Peer-Details aus config.xml
+func (h *Handler) getPeerByUUID(configXML, uuid string) map[string]interface{} {
+ type clientEntry struct {
+ UUID string `xml:"uuid,attr"`
+ Enabled string `xml:"enabled"`
+ Name string `xml:"name"`
+ PubKey string `xml:"pubkey"`
+ TunnelAddress string `xml:"tunneladdress"`
+ }
+ type opnsenseClients struct {
+ Clients []clientEntry `xml:"OPNsense>wireguard>client>clients>client"`
+ }
+
+ var cfg opnsenseClients
+ xml.Unmarshal([]byte(configXML), &cfg)
+
+ for _, c := range cfg.Clients {
+ if c.UUID == uuid {
+ return map[string]interface{}{
+ "uuid": c.UUID,
+ "enabled": c.Enabled,
+ "name": c.Name,
+ "public_key": c.PubKey,
+ "tunnel_address": c.TunnelAddress,
+ }
+ }
+ }
+ return nil
+}
+
+// nextFreeIP berechnet die naechste freie IP in einem Subnetz
+func (h *Handler) nextFreeIP(tunnelAddr string, usedIPs []string) (string, error) {
+ // tunnelAddr: "10.33.144.1/24"
+ _, ipNet, err := net.ParseCIDR(tunnelAddr)
+ if err != nil {
+ return "", fmt.Errorf("Subnetz parsen: %v", err)
+ }
+
+ usedSet := make(map[string]bool)
+ // Server-IP selbst ist auch belegt
+ serverIP := strings.Split(tunnelAddr, "/")[0]
+ usedSet[serverIP] = true
+ for _, ip := range usedIPs {
+ usedSet[ip] = true
+ }
+
+ // Ab .2 durchiterieren
+ ip := make(net.IP, len(ipNet.IP))
+ copy(ip, ipNet.IP)
+
+ for i := 2; i < 255; i++ {
+ candidate := make(net.IP, len(ip))
+ copy(candidate, ip)
+ candidate[len(candidate)-1] = byte(i)
+
+ if !ipNet.Contains(candidate) {
+ break
+ }
+ if !usedSet[candidate.String()] {
+ return candidate.String(), nil
+ }
+ }
+
+ return "", fmt.Errorf("kein freier IP-Platz im Subnetz %s", tunnelAddr)
+}
+
+// addPeerViaSed fuegt Peer per sed-Kommandos in config.xml ein
+// Zuverlaessiger als PHP parse_config, behandelt und ... korrekt
+func (h *Handler) addPeerViaSed(peerUUID, name, pubKey, clientIP, serverUUID string) error {
+ configPath := "/conf/config.xml"
+
+ // 1. Client-Block vor einfuegen (direkt in Go)
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ return fmt.Errorf("config.xml lesen: %v", err)
+ }
+
+ clientBlock := fmt.Sprintf(
+ "\n \n 1\n %s\n %s\n \n %s/32\n \n \n \n \n ",
+ peerUUID, name, pubKey, clientIP)
+
+ configStr := string(configData)
+ // Vor dem letzten einfuegen
+ lastIdx := strings.LastIndex(configStr, "")
+ if lastIdx < 0 {
+ return fmt.Errorf(" nicht in config.xml gefunden")
+ }
+ configStr = configStr[:lastIdx] + clientBlock + configStr[lastIdx:]
+
+ if err := os.WriteFile(configPath, []byte(configStr), 0644); err != nil {
+ return fmt.Errorf("config.xml schreiben: %v", err)
+ }
+
+ // 2. Peer-UUID zum Server hinzufuegen
+ // Config nochmal lesen (wurde gerade geschrieben)
+ configData2, err2 := os.ReadFile(configPath)
+ if err2 != nil {
+ return fmt.Errorf("config.xml lesen: %v", err2)
+ }
+ configStr = string(configData2)
+
+ serverTag := fmt.Sprintf(`uuid="%s"`, serverUUID)
+ idx := strings.Index(configStr, serverTag)
+ if idx < 0 {
+ return fmt.Errorf("Server %s nicht gefunden", serverUUID)
+ }
+
+ // Suche im Server-Block (max 2000 Zeichen nach dem UUID-Tag)
+ searchEnd := idx + 2000
+ if searchEnd > len(configStr) {
+ searchEnd = len(configStr)
+ }
+ searchFrom := configStr[idx:searchEnd]
+
+ // Fall 1: (self-closing, leer)
+ selfClosingIdx := strings.Index(searchFrom, "")
+ if selfClosingIdx >= 0 {
+ absIdx := idx + selfClosingIdx
+ newPeersTag := fmt.Sprintf("%s", peerUUID)
+ configStr = configStr[:absIdx] + newPeersTag + configStr[absIdx+8:]
+ } else {
+ // Fall 2: ...
+ peersStart := strings.Index(searchFrom, "")
+ peersEnd := strings.Index(searchFrom, "")
+ if peersStart >= 0 && peersEnd >= 0 {
+ absStart := idx + peersStart + 7
+ absEnd := idx + peersEnd
+ currentPeers := strings.TrimSpace(configStr[absStart:absEnd])
+ var newPeers string
+ if currentPeers == "" {
+ newPeers = peerUUID
+ } else {
+ newPeers = currentPeers + "," + peerUUID
+ }
+ configStr = configStr[:absStart] + newPeers + configStr[absEnd:]
+ } else {
+ return fmt.Errorf("peers Tag nicht im Server-Block gefunden")
+ }
+ }
+
+ return os.WriteFile(configPath, []byte(configStr), 0644)
+}
+
+// removePeerViaSed entfernt einen Peer per sed aus config.xml
+func (h *Handler) removePeerViaSed(peerUUID string) error {
+ configPath := "/conf/config.xml"
+
+ // 1. Client-Block entfernen (alles von bis )
+ sedRemove := fmt.Sprintf(
+ `/usr/bin/sed -i '' '//,/<\/client>/d' %s`,
+ peerUUID, configPath)
+ if _, err := h.runCommand(sedRemove, 5); err != nil {
+ return fmt.Errorf("Client entfernen: %v", err)
+ }
+
+ // 2. UUID aus allen -Tags entfernen
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ return err
+ }
+ configStr := string(configData)
+
+ // Alle Vorkommen der UUID in -Tags bereinigen
+ // Varianten: "uuid", "uuid,other", "other,uuid", "other,uuid,more"
+ replacements := []struct{ old, new string }{
+ {fmt.Sprintf("%s,", peerUUID), ""},
+ {fmt.Sprintf(",%s", peerUUID), ""},
+ {fmt.Sprintf("%s", peerUUID), ""},
+ {fmt.Sprintf(",%s,", peerUUID), ","},
+ }
+
+ for _, r := range replacements {
+ configStr = strings.ReplaceAll(configStr, r.old, r.new)
+ }
+
+ return os.WriteFile(configPath, []byte(configStr), 0644)
+}
diff --git a/backend/api/tunnel_proxy.go b/backend/api/tunnel_proxy.go
index 3c3742a..618fca9 100644
--- a/backend/api/tunnel_proxy.go
+++ b/backend/api/tunnel_proxy.go
@@ -26,6 +26,11 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("PUT /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub))
mux.HandleFunc("DELETE /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub))
+ // WireGuard-Endpoints
+ mux.HandleFunc("POST /api/v1/agents/{id}/wireguard/peers", h.wgAddPeer(hub))
+ mux.HandleFunc("GET /api/v1/agents/{id}/wireguard/peers", h.wgListPeers(hub))
+ mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub))
+
// Backup-Endpoints
mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub))
mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups())
diff --git a/backend/api/wireguard.go b/backend/api/wireguard.go
new file mode 100644
index 0000000..8b0a1a4
--- /dev/null
+++ b/backend/api/wireguard.go
@@ -0,0 +1,100 @@
+package api
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/cynfo/rmm-backend/ws"
+)
+
+// POST /api/v1/agents/{id}/wireguard/peers — Neuen WG-Peer anlegen
+func (h *Handler) wgAddPeer(hub *ws.Hub) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ agentID := r.PathValue("id")
+
+ var req struct {
+ Name string `json:"name"`
+ AllowedIPs string `json:"allowed_ips"`
+ DNS string `json:"dns"`
+ ServerName string `json:"server_name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
+ writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
+ return
+ }
+
+ params := map[string]interface{}{
+ "name": req.Name,
+ }
+ if req.AllowedIPs != "" {
+ params["allowed_ips"] = req.AllowedIPs
+ }
+ if req.DNS != "" {
+ params["dns"] = req.DNS
+ }
+ if req.ServerName != "" {
+ params["server_name"] = req.ServerName
+ }
+
+ h.sendCommandAndWait(hub, agentID, "wg_add_peer", params, 30, w)
+ }
+}
+
+// DELETE /api/v1/agents/{id}/wireguard/peers/{uuid} — Peer loeschen
+func (h *Handler) wgDeletePeer(hub *ws.Hub) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ agentID := r.PathValue("id")
+ peerUUID := r.PathValue("uuid")
+
+ params := map[string]interface{}{
+ "peer_uuid": peerUUID,
+ }
+
+ h.sendCommandAndWait(hub, agentID, "wg_delete_peer", params, 30, w)
+ }
+}
+
+// GET /api/v1/agents/{id}/wireguard/peers — Alle Peers auflisten
+func (h *Handler) wgListPeers(hub *ws.Hub) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ agentID := r.PathValue("id")
+
+ if !hub.IsAgentConnected(agentID) {
+ writeError(w, http.StatusServiceUnavailable, "Agent nicht verbunden")
+ return
+ }
+
+ serverName := r.URL.Query().Get("server_name")
+ params := map[string]interface{}{}
+ if serverName != "" {
+ params["server_name"] = serverName
+ }
+
+ cmdID := generateID()
+ cmd := map[string]interface{}{
+ "type": "command",
+ "id": cmdID,
+ "command": "wg_list_peers",
+ "params": params,
+ }
+
+ responseCh := hub.RegisterResponseWaiter(cmdID)
+ defer hub.UnregisterResponseWaiter(cmdID)
+
+ cmdJSON, _ := json.Marshal(cmd)
+ if err := hub.SendToAgent(agentID, cmdJSON); err != nil {
+ log.Printf("WG list peers senden fehlgeschlagen: %v", err)
+ writeError(w, http.StatusInternalServerError, "Command senden fehlgeschlagen")
+ return
+ }
+
+ select {
+ case resp := <-responseCh:
+ writeJSON(w, http.StatusOK, resp)
+ case <-time.After(30 * time.Second):
+ writeError(w, http.StatusGatewayTimeout, "Timeout")
+ }
+ }
+}