package infra import ( "fmt" "os" "os/exec" "strings" ) // WGPeerRemoveStatus indica el resultado de la operacion de borrado. type WGPeerRemoveStatus string const ( WGPeerRemoveStatusRemoved WGPeerRemoveStatus = "removed" WGPeerRemoveStatusNotPresent WGPeerRemoveStatus = "not-present" ) // WGPeerRemoveResult contiene el resultado de WGPeerRemove. type WGPeerRemoveResult struct { DeviceID string Status WGPeerRemoveStatus ConfigPath string } // WGPeerRemove elimina el bloque [Peer] asociado a deviceID del archivo configPath // buscando el comentario "# DeviceID:" y aplica syncconf en caliente. // Es idempotente: si el peer no existe devuelve status=not-present sin error. // // Formato esperado del config (el comentario puede preceder o estar dentro del bloque): // // # DeviceID:device-001 // [Peer] // PublicKey = ... // AllowedIPs = ... func WGPeerRemove(deviceID string, configPath string) (WGPeerRemoveResult, error) { result := WGPeerRemoveResult{ DeviceID: deviceID, ConfigPath: configPath, } if strings.TrimSpace(deviceID) == "" { return result, fmt.Errorf("wg_peer_remove: deviceID cannot be empty") } if strings.TrimSpace(configPath) == "" { return result, fmt.Errorf("wg_peer_remove: configPath cannot be empty") } data, err := os.ReadFile(configPath) if err != nil { return result, fmt.Errorf("wg_peer_remove: read config %s: %w", configPath, err) } lines := strings.Split(string(data), "\n") marker := fmt.Sprintf("# DeviceID:%s", deviceID) // Localizar el marker. markerIdx := -1 for i, line := range lines { if strings.TrimSpace(line) == marker { markerIdx = i break } } if markerIdx == -1 { result.Status = WGPeerRemoveStatusNotPresent return result, nil } // blockStart: la primera linea del bloque a eliminar. // Si el marker precede al [Peer], el bloque empieza en el marker. // Si el marker esta dentro del [Peer], retrocedemos hasta el [Peer] o su comentario previo. blockStart := markerIdx for j := markerIdx - 1; j >= 0; j-- { t := strings.TrimSpace(lines[j]) if t == "[Peer]" { blockStart = j // Incluir tambien el comentario # DeviceID que preceda a ese [Peer]. if j > 0 && strings.HasPrefix(strings.TrimSpace(lines[j-1]), "# DeviceID:") { blockStart = j - 1 } break } if strings.HasPrefix(t, "[") && t != "" { // Llegamos a otra seccion — el marker precede al [Peer]. break } } // blockEnd: primera linea que abre el SIGUIENTE bloque tras el [Peer] actual. // Saltamos hasta pasar el [Peer] propio antes de buscar otra seccion. blockEnd := len(lines) pastOwnPeer := false for i := blockStart; i < len(lines); i++ { t := strings.TrimSpace(lines[i]) if !pastOwnPeer { if t == "[Peer]" { pastOwnPeer = true } continue } if (strings.HasPrefix(t, "[") && t != "") || strings.HasPrefix(t, "# DeviceID:") { blockEnd = i break } } // Reconstruir: segmento antes del bloque + segmento desde blockEnd. before := lines[:blockStart] after := lines[blockEnd:] // Quitar lineas vacias finales del segmento anterior. for len(before) > 0 && strings.TrimSpace(before[len(before)-1]) == "" { before = before[:len(before)-1] } var newLines []string newLines = append(newLines, before...) if len(after) > 0 { newLines = append(newLines, "") } newLines = append(newLines, after...) newContent := strings.TrimRight(strings.Join(newLines, "\n"), "\n") + "\n" if err := os.WriteFile(configPath, []byte(newContent), 0600); err != nil { return result, fmt.Errorf("wg_peer_remove: write config %s: %w", configPath, err) } // Aplicar syncconf en caliente. iface := wgIfaceFromPath(configPath) if iface != "" { if err := wgSyncConfFn(iface, configPath); err != nil { _ = os.WriteFile(configPath, data, 0600) return result, fmt.Errorf("wg_peer_remove: syncconf %s: %w", iface, err) } } result.Status = WGPeerRemoveStatusRemoved return result, nil } // wgIfaceFromPath extrae el nombre de interfaz del path del config (ej. wg0 de /etc/wireguard/wg0.conf). func wgIfaceFromPath(configPath string) string { parts := strings.Split(configPath, "/") if len(parts) == 0 { return "" } name := parts[len(parts)-1] name = strings.TrimSuffix(name, ".conf") for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { return "" } } return name } // wgSyncConfFn aplica `wg syncconf ` en caliente. Variable // para permitir override en tests (no requiere binario `wg`). WG_SKIP_SYNCCONF=1 // salta la ejecucion (CI sin wg-tools). var wgSyncConfFn = func(iface, configPath string) error { if os.Getenv("WG_SKIP_SYNCCONF") == "1" { return nil } cmd := exec.Command("wg", "syncconf", iface, configPath) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } return nil } // wgLookupPeerPublicKey busca la PublicKey del peer identificado por deviceID en configPath. // El comentario "# DeviceID:" puede preceder al [Peer] o estar dentro del bloque. func wgLookupPeerPublicKey(deviceID, configPath string) (string, error) { data, err := os.ReadFile(configPath) if err != nil { return "", fmt.Errorf("wg_lookup_peer: read %s: %w", configPath, err) } lines := strings.Split(string(data), "\n") marker := fmt.Sprintf("# DeviceID:%s", deviceID) markerIdx := -1 for i, line := range lines { if strings.TrimSpace(line) == marker { markerIdx = i break } } if markerIdx == -1 { return "", fmt.Errorf("wg_lookup_peer: peer %s not found in %s", deviceID, configPath) } // Buscar PublicKey hacia adelante desde el marker (saltando la linea [Peer]). for i := markerIdx + 1; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) if line == "[Peer]" { continue } if (strings.HasPrefix(line, "[") && line != "") || strings.HasPrefix(line, "# DeviceID:") { break } if strings.HasPrefix(line, "PublicKey") { if idx := strings.Index(line, " = "); idx != -1 { return strings.TrimSpace(line[idx+3:]), nil } if idx := strings.Index(line, "="); idx != -1 { return strings.TrimSpace(line[idx+1:]), nil } } } // Fallback: buscar hacia atras (marker dentro del bloque). for i := markerIdx - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) if strings.HasPrefix(line, "[") && line != "" { break } if strings.HasPrefix(line, "PublicKey") { if idx := strings.Index(line, " = "); idx != -1 { return strings.TrimSpace(line[idx+3:]), nil } if idx := strings.Index(line, "="); idx != -1 { return strings.TrimSpace(line[idx+1:]), nil } } } return "", fmt.Errorf("wg_lookup_peer: PublicKey not found for peer %s in %s", deviceID, configPath) }