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) }