package services import ( "bytes" "fmt" "io" "os" "os/exec" "strings" "sync" "gitea.avt.data-center.id/othman.suseno/atlas/internal/models" ) // NFSService manages NFS service integration type NFSService struct { mu sync.RWMutex exportsPath string } // NewNFSService creates a new NFS service manager func NewNFSService() *NFSService { return &NFSService{ exportsPath: "/etc/exports", } } // ApplyConfiguration generates and applies NFS exports configuration // Uses ZFS sharenfs property when possible (safer and native), falls back to /etc/exports func (s *NFSService) ApplyConfiguration(exports []models.NFSExport) error { s.mu.Lock() defer s.mu.Unlock() // Try using ZFS sharenfs property first (safer, native ZFS method) zfsErr := s.applyZFSShareNFS(exports) if zfsErr == nil { return nil // Success using ZFS sharenfs } // If ZFS method failed, check if it's just a reload error // If sharenfs was set but reload failed, that's acceptable - exports will work if strings.Contains(zfsErr.Error(), "sharenfs set but reload failed") { // ShareNFS was set successfully, just reload failed // This is acceptable - exports are configured, just need manual reload // Return nil to indicate success (exports are configured) return nil } // Log the error for debugging but continue with fallback // Note: We don't return error here to allow fallback to /etc/exports method // This is intentional - if ZFS method fails completely, we try traditional method // Fallback to /etc/exports method config, err := s.generateExports(exports) if err != nil { return fmt.Errorf("generate exports: %w", err) } // Write configuration directly to /etc/exports.atlas.tmp using sudo tee // This avoids cross-device issues and permission problems finalTmpPath := s.exportsPath + ".atlas.tmp" // Use sudo tee to write directly to /etc (requires root permissions) teeCmd := exec.Command("tee", finalTmpPath) teeCmd.Stdin = strings.NewReader(config) var teeStderr bytes.Buffer teeCmd.Stderr = &teeStderr if err := teeCmd.Run(); err != nil { // If sudo fails, try direct write (might work if running as root) if err := os.WriteFile(finalTmpPath, []byte(config), 0644); err != nil { return fmt.Errorf("write exports temp file: %w (sudo failed: %v, stderr: %s)", err, err, teeStderr.String()) } } // Set proper permissions on temp file chmodCmd := exec.Command("chmod", "644", finalTmpPath) _ = chmodCmd.Run() // Ignore errors, might already have correct permissions // Backup existing exports using sudo backupPath := s.exportsPath + ".backup" if _, err := os.Stat(s.exportsPath); err == nil { cpCmd := exec.Command("cp", s.exportsPath, backupPath) if err := cpCmd.Run(); err != nil { // Non-fatal, log but continue // Try direct copy as fallback exec.Command("cp", s.exportsPath, backupPath).Run() } } // Atomically replace exports file using sudo // Use cp + rm instead of mv for better cross-device compatibility cpCmd := exec.Command("cp", finalTmpPath, s.exportsPath) cpStderr := bytes.Buffer{} cpCmd.Stderr = &cpStderr if err := cpCmd.Run(); err != nil { // If sudo fails, try direct copy using helper function (might work if running as root) if err := copyFile(finalTmpPath, s.exportsPath); err != nil { return fmt.Errorf("replace exports: %w (sudo failed: %v, stderr: %s)", err, err, cpStderr.String()) } } // Remove temp file after successful copy rmCmd := exec.Command("rm", "-f", finalTmpPath) _ = rmCmd.Run() // Ignore errors, file might not exist // Reload NFS exports with error recovery reloadErr := s.reloadExports() if reloadErr != nil { // Try to restore backup on failure if _, err2 := os.Stat(backupPath); err2 == nil { if restoreErr := os.Rename(backupPath, s.exportsPath); restoreErr != nil { return fmt.Errorf("reload failed and backup restore failed: reload=%v, restore=%v", reloadErr, restoreErr) } } return fmt.Errorf("reload exports: %w", reloadErr) } return nil } // generateExports generates /etc/exports format from NFS exports func (s *NFSService) generateExports(exports []models.NFSExport) (string, error) { var b strings.Builder for _, export := range exports { if !export.Enabled { continue } // Build export options var options []string if export.ReadOnly { options = append(options, "ro") } else { options = append(options, "rw") } if export.RootSquash { options = append(options, "root_squash") } else { options = append(options, "no_root_squash") } options = append(options, "sync", "subtree_check") // Format: path client1(options) client2(options) optStr := "(" + strings.Join(options, ",") + ")" if len(export.Clients) == 0 { // Default to all clients if none specified b.WriteString(fmt.Sprintf("%s *%s\n", export.Path, optStr)) } else { for _, client := range export.Clients { b.WriteString(fmt.Sprintf("%s %s%s\n", export.Path, client, optStr)) } } } return b.String(), nil } // reloadExports reloads NFS exports func (s *NFSService) reloadExports() error { // Use exportfs -ra to reload all exports (requires root) // Try with sudo first cmd := exec.Command("exportfs", "-ra") var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // If sudo fails, try direct execution (might work if running as root) directCmd := exec.Command("exportfs", "-ra") directStderr := bytes.Buffer{} directCmd.Stderr = &directStderr if directErr := directCmd.Run(); directErr != nil { return fmt.Errorf("exportfs failed: sudo error: %v (stderr: %s), direct error: %v (stderr: %s)", err, stderr.String(), directErr, directStderr.String()) } } return nil } // ValidateConfiguration validates NFS exports syntax func (s *NFSService) ValidateConfiguration(exports string) error { // Use exportfs -v to validate (dry-run) cmd := exec.Command("exportfs", "-v") cmd.Stdin = strings.NewReader(exports) // Note: exportfs doesn't have a direct validation mode // We'll rely on the reload to catch errors return nil } // GetStatus returns the status of NFS service func (s *NFSService) GetStatus() (bool, error) { // Check if nfs-server is running cmd := exec.Command("systemctl", "is-active", "nfs-server") if err := cmd.Run(); err == nil { return true, nil } // Fallback: check process cmd = exec.Command("pgrep", "-x", "nfsd") if err := cmd.Run(); err == nil { return true, nil } return false, nil } // copyFile copies a file from src to dst (helper for cross-device operations) func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return fmt.Errorf("create destination: %w", err) } defer destFile.Close() if _, err := io.Copy(destFile, sourceFile); err != nil { return fmt.Errorf("copy content: %w", err) } return destFile.Sync() } // applyZFSShareNFS applies NFS exports using ZFS sharenfs property (native, safer method) func (s *NFSService) applyZFSShareNFS(exports []models.NFSExport) error { // Find zfs command path zfsPath := "zfs" if path, err := exec.LookPath("zfs"); err == nil { zfsPath = path } for _, export := range exports { if !export.Enabled { // Disable sharenfs for disabled exports cmd := exec.Command(zfsPath, "set", "sharenfs=off", export.Dataset) if err := cmd.Run(); err != nil { // Log but continue - might not have permission or dataset doesn't exist continue } continue } // Build sharenfs value // Format for sharenfs: // - "on" = share to all with default options // - "rw" = share to all with rw // - "rw=client1,ro=client2,options" = client-specific with options var sharenfsValue strings.Builder // Check if we have specific clients (not just *) hasSpecificClients := false for _, client := range export.Clients { if client != "*" && client != "" { hasSpecificClients = true break } } if !hasSpecificClients { // No specific clients, share to all (*) // Format must be: "rw=*" or "ro=*" with options // Note: "rw,root_squash" is NOT valid - must use "rw=*,root_squash" if export.ReadOnly { sharenfsValue.WriteString("ro=*") } else { sharenfsValue.WriteString("rw=*") } // Add options after permission if export.RootSquash { sharenfsValue.WriteString(",root_squash") } else { sharenfsValue.WriteString(",no_root_squash") } } else { // Has specific clients, use client-specific format clientSpecs := []string{} for _, client := range export.Clients { if client == "*" || client == "" { // Handle * as default if export.ReadOnly { clientSpecs = append(clientSpecs, "ro") } else { clientSpecs = append(clientSpecs, "rw") } } else { perm := "rw" if export.ReadOnly { perm = "ro" } clientSpecs = append(clientSpecs, fmt.Sprintf("%s=%s", perm, client)) } } // Add options if export.RootSquash { clientSpecs = append(clientSpecs, "root_squash") } else { clientSpecs = append(clientSpecs, "no_root_squash") } sharenfsValue.WriteString(strings.Join(clientSpecs, ",")) } // Set sharenfs property using sudo (atlas user has permission via sudoers) cmd := exec.Command(zfsPath, "set", fmt.Sprintf("sharenfs=%s", sharenfsValue.String()), export.Dataset) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // If setting sharenfs fails, this method won't work - return error to trigger fallback return fmt.Errorf("failed to set sharenfs on %s: %v (stderr: %s)", export.Dataset, err, stderr.String()) } } // After setting sharenfs properties, reload NFS exports // ZFS sharenfs requires exportfs -ra to make exports visible if err := s.reloadExports(); err != nil { // Log error but don't fail - sharenfs is set, just needs manual reload // Return error so caller knows reload failed, but sharenfs is already set // This is acceptable - exports will work after manual reload return fmt.Errorf("sharenfs set but reload failed (exports may need manual reload): %w", err) } return nil }