rmm2/agent-bsd/ws/wireguard.go

605 lines
17 KiB
Go

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 <peers/> und <peers>...</peers> korrekt
func (h *Handler) addPeerViaSed(peerUUID, name, pubKey, clientIP, serverUUID string) error {
configPath := "/conf/config.xml"
// 1. Client-Block vor </clients> einfuegen (direkt in Go)
configData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("config.xml lesen: %v", err)
}
clientBlock := fmt.Sprintf(
"\n <client uuid=\"%s\">\n <enabled>1</enabled>\n <name>%s</name>\n <pubkey>%s</pubkey>\n <psk/>\n <tunneladdress>%s/32</tunneladdress>\n <serveraddress/>\n <serverport/>\n <keepalive/>\n </client>\n ",
peerUUID, name, pubKey, clientIP)
configStr := string(configData)
// Vor dem letzten </clients> einfuegen
lastIdx := strings.LastIndex(configStr, "</clients>")
if lastIdx < 0 {
return fmt.Errorf("</clients> 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: <peers/> (self-closing, leer)
selfClosingIdx := strings.Index(searchFrom, "<peers/>")
if selfClosingIdx >= 0 {
absIdx := idx + selfClosingIdx
newPeersTag := fmt.Sprintf("<peers>%s</peers>", peerUUID)
configStr = configStr[:absIdx] + newPeersTag + configStr[absIdx+8:]
} else {
// Fall 2: <peers>...</peers>
peersStart := strings.Index(searchFrom, "<peers>")
peersEnd := strings.Index(searchFrom, "</peers>")
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 <client uuid="..."> bis </client>)
sedRemove := fmt.Sprintf(
`/usr/bin/sed -i '' '/<client uuid="%s">/,/<\/client>/d' %s`,
peerUUID, configPath)
if _, err := h.runCommand(sedRemove, 5); err != nil {
return fmt.Errorf("Client entfernen: %v", err)
}
// 2. UUID aus allen <peers>-Tags entfernen
configData, err := os.ReadFile(configPath)
if err != nil {
return err
}
configStr := string(configData)
// Alle Vorkommen der UUID in <peers>-Tags bereinigen
// Varianten: "uuid", "uuid,other", "other,uuid", "other,uuid,more"
replacements := []struct{ old, new string }{
{fmt.Sprintf("<peers>%s,", peerUUID), "<peers>"},
{fmt.Sprintf(",%s</peers>", peerUUID), "</peers>"},
{fmt.Sprintf("<peers>%s</peers>", peerUUID), "<peers/>"},
{fmt.Sprintf(",%s,", peerUUID), ","},
}
for _, r := range replacements {
configStr = strings.ReplaceAll(configStr, r.old, r.new)
}
return os.WriteFile(configPath, []byte(configStr), 0644)
}