package infra import ( "bufio" "fmt" "net" "os" "strings" ) // WGPeerAdd añade un peer WireGuard al wg0.conf del hub y aplica la config // en caliente con `wg syncconf` sin reiniciar la interface. // // Idempotente: // - Si PublicKey ya está presente con la misma config → "already-present". // - Si DeviceID existe con otra PublicKey → reemplaza el bloque → "reconfigured". // - Si AllowedIPs está vacío, asigna la primera IP libre de subnetCIDR (excluyendo .1). // // Escribe atómicamente (tmpfile + rename) y hace chmod 600 sobre configPath. // Si syncconf falla, restaura el backup y devuelve error. // // Para tests CI sin WireGuard real, establecer WG_SKIP_SYNCCONF=1. func WGPeerAdd(spec WGPeerSpec, configPath string, subnetCIDR string) (WGPeerResult, error) { // --- leer config actual --- existing, err := os.ReadFile(configPath) if err != nil && !os.IsNotExist(err) { return WGPeerResult{}, fmt.Errorf("wg_peer_add: read config: %w", err) } content := string(existing) // --- parsear peers existentes --- peers, err := wgParsePeers(content) if err != nil { return WGPeerResult{}, fmt.Errorf("wg_peer_add: parse peers: %w", err) } // --- idempotencia: buscar peer existente ANTES de asignar IP --- status := "added" existingBlock, foundByKey := peers[spec.PublicKey] _, foundByDevice := wgFindByDeviceID(peers, spec.DeviceID) // --- determinar AllowedIPs --- allowedIPs := spec.AllowedIPs if allowedIPs == "" { if foundByKey && existingBlock.allowedIPs != "" { // reusar la IP del peer existente para idempotencia allowedIPs = existingBlock.allowedIPs } else { ip, err := wgNextFreeIP(subnetCIDR, peers) if err != nil { return WGPeerResult{}, fmt.Errorf("wg_peer_add: assign ip: %w", err) } allowedIPs = ip + "/32" } } // extraer IP pura para el resultado assignedIP := allowedIPs if idx := strings.Index(allowedIPs, "/"); idx >= 0 { assignedIP = allowedIPs[:idx] } if foundByKey { // misma PublicKey ya está — verificar si la config coincide if existingBlock.allowedIPs == allowedIPs && existingBlock.presharedKey == spec.PresharedKey { return WGPeerResult{ DeviceID: spec.DeviceID, AssignedIP: assignedIP, ConfigPath: configPath, Status: "already-present", }, nil } status = "reconfigured" } else if foundByDevice { // DeviceID existe con otra PublicKey → reconfigured (replace) status = "reconfigured" } // --- construir nueva config --- newContent, err := wgRebuildConfig(content, spec, allowedIPs, status) if err != nil { return WGPeerResult{}, fmt.Errorf("wg_peer_add: rebuild config: %w", err) } // --- backup --- backupPath := configPath + ".bak" if len(existing) > 0 { if err := os.WriteFile(backupPath, existing, 0600); err != nil { return WGPeerResult{}, fmt.Errorf("wg_peer_add: backup: %w", err) } } // --- escritura atómica --- tmpPath := configPath + ".tmp" if err := os.WriteFile(tmpPath, []byte(newContent), 0600); err != nil { return WGPeerResult{}, fmt.Errorf("wg_peer_add: write tmp: %w", err) } if err := os.Rename(tmpPath, configPath); err != nil { _ = os.Remove(tmpPath) return WGPeerResult{}, fmt.Errorf("wg_peer_add: rename: %w", err) } _ = os.Chmod(configPath, 0600) // --- syncconf --- iface := wgIfaceFromPath(configPath) if iface != "" { if err := wgSyncConfFn(iface, configPath); err != nil { // restaurar backup if len(existing) > 0 { _ = os.WriteFile(configPath, existing, 0600) } return WGPeerResult{}, fmt.Errorf("wg_peer_add: syncconf: %w", err) } } return WGPeerResult{ DeviceID: spec.DeviceID, AssignedIP: assignedIP, ConfigPath: configPath, Status: status, }, nil } // --- helpers internos --- type wgPeerBlock struct { deviceID string publicKey string presharedKey string allowedIPs string rawLines []string // líneas originales del bloque incluyendo comentario # DeviceID } // wgParsePeers extrae todos los bloques [Peer] indexados por PublicKey. func wgParsePeers(content string) (map[string]*wgPeerBlock, error) { peers := map[string]*wgPeerBlock{} scanner := bufio.NewScanner(strings.NewReader(content)) var cur *wgPeerBlock var pendingDeviceID string for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) // comentario de tracking: # DeviceID: if strings.HasPrefix(trimmed, "# DeviceID:") { pendingDeviceID = strings.TrimSpace(strings.TrimPrefix(trimmed, "# DeviceID:")) if cur != nil { cur.rawLines = append(cur.rawLines, line) } continue } if trimmed == "[Peer]" { cur = &wgPeerBlock{deviceID: pendingDeviceID} cur.rawLines = append(cur.rawLines, line) pendingDeviceID = "" continue } if cur != nil { if trimmed == "" || strings.HasPrefix(trimmed, "[") { // fin del bloque if cur.publicKey != "" { peers[cur.publicKey] = cur } cur = nil if strings.HasPrefix(trimmed, "[") { // nueva sección, no es [Peer] } continue } cur.rawLines = append(cur.rawLines, line) if k, v, ok := wgKV(trimmed); ok { switch k { case "PublicKey": cur.publicKey = v case "PresharedKey": cur.presharedKey = v case "AllowedIPs": cur.allowedIPs = v } } } } if cur != nil && cur.publicKey != "" { peers[cur.publicKey] = cur } return peers, scanner.Err() } // wgFindByDeviceID busca un peer por DeviceID. func wgFindByDeviceID(peers map[string]*wgPeerBlock, deviceID string) (*wgPeerBlock, bool) { for _, p := range peers { if p.deviceID == deviceID { return p, true } } return nil, false } // wgNextFreeIP encuentra la primera IP libre en subnetCIDR (excluyendo .1 del hub). func wgNextFreeIP(subnetCIDR string, peers map[string]*wgPeerBlock) (string, error) { ip, ipNet, err := net.ParseCIDR(subnetCIDR) if err != nil { return "", fmt.Errorf("invalid subnetCIDR %q: %w", subnetCIDR, err) } _ = ip // recopilar IPs ya usadas used := map[string]bool{} for _, p := range peers { cidr := p.allowedIPs if idx := strings.Index(cidr, "/"); idx >= 0 { used[cidr[:idx]] = true } } // iterar desde .2 (hub es .1) for candidate := wgIncrIP(ipNet.IP); ipNet.Contains(candidate); candidate = wgIncrIP(candidate) { s := candidate.String() // saltar la dirección de red (.0) y broadcast if s == ipNet.IP.String() { continue } // saltar hub (.1) hubIP := wgIncrIP(ipNet.IP) if s == hubIP.String() { // esta es .1 (hub), saltar continue } if !used[s] { return s, nil } } return "", fmt.Errorf("no free IPs in %s", subnetCIDR) } // wgIncrIP incrementa una IP en 1. func wgIncrIP(ip net.IP) net.IP { ip = ip.To4() if ip == nil { return nil } result := make(net.IP, 4) copy(result, ip) for i := 3; i >= 0; i-- { result[i]++ if result[i] != 0 { break } } return result } // wgRebuildConfig reconstruye el contenido del config con el peer añadido/reemplazado. func wgRebuildConfig(content string, spec WGPeerSpec, allowedIPs, status string) (string, error) { // construir nuevo bloque var newBlock strings.Builder newBlock.WriteString(fmt.Sprintf("# DeviceID: %s\n", spec.DeviceID)) newBlock.WriteString("[Peer]\n") newBlock.WriteString(fmt.Sprintf("PublicKey = %s\n", spec.PublicKey)) if spec.PresharedKey != "" { newBlock.WriteString(fmt.Sprintf("PresharedKey = %s\n", spec.PresharedKey)) } newBlock.WriteString(fmt.Sprintf("AllowedIPs = %s\n", allowedIPs)) if status == "added" { // simplemente añadir al final result := content if !strings.HasSuffix(result, "\n") && len(result) > 0 { result += "\n" } result += "\n" + newBlock.String() return result, nil } // reconfigured: reemplazar el bloque existente (buscar por DeviceID o PublicKey) result, err := wgReplaceBlock(content, spec, newBlock.String()) if err != nil { return "", err } return result, nil } // wgReplaceBlock reemplaza el bloque del peer identificado por DeviceID o PublicKey. // Estrategia: parsear el config en segmentos (pre-bloque / bloque-target / post-bloque) // y reconstruir sustituyendo solo el bloque target. func wgReplaceBlock(content string, spec WGPeerSpec, newBlock string) (string, error) { type segment struct { isPeer bool lines []string // incluye comentario # DeviceID si lo había pk string did string } var segments []segment scanner := bufio.NewScanner(strings.NewReader(content)) var cur *segment var pendingComment string flush := func() { if cur != nil { segments = append(segments, *cur) cur = nil } } for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "# DeviceID:") { pendingComment = line continue } if trimmed == "[Peer]" { flush() seg := segment{isPeer: true} if pendingComment != "" { seg.lines = append(seg.lines, pendingComment) seg.did = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(pendingComment), "# DeviceID:")) pendingComment = "" } seg.lines = append(seg.lines, line) cur = &seg continue } if cur != nil && cur.isPeer { if trimmed == "" || strings.HasPrefix(trimmed, "[") { flush() if pendingComment != "" { segments = append(segments, segment{lines: []string{pendingComment}}) pendingComment = "" } if trimmed != "" { cur = &segment{lines: []string{line}} } continue } cur.lines = append(cur.lines, line) if k, v, ok := wgKV(trimmed); ok && k == "PublicKey" { cur.pk = v } continue } // línea fuera de bloque peer if pendingComment != "" { if cur == nil { cur = &segment{} } cur.lines = append(cur.lines, pendingComment) pendingComment = "" } if cur == nil { cur = &segment{} } cur.lines = append(cur.lines, line) } flush() if err := scanner.Err(); err != nil { return "", err } // reconstruir sustituyendo el target var out strings.Builder replaced := false for _, seg := range segments { if seg.isPeer && !replaced { isTarget := (seg.pk == spec.PublicKey) || (spec.DeviceID != "" && seg.did == spec.DeviceID) if isTarget { out.WriteString("\n" + newBlock) replaced = true continue } } for _, l := range seg.lines { out.WriteString(l + "\n") } } if !replaced { result := out.String() if !strings.HasSuffix(result, "\n") && len(result) > 0 { result += "\n" } result += "\n" + newBlock return result, nil } return out.String(), nil } // wgKV parsea "Key = Value" devolviendo (key, value, true) o ("", "", false). func wgKV(line string) (string, string, bool) { idx := strings.IndexByte(line, '=') if idx < 0 { return "", "", false } return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), true }