333 lines
10 KiB
Go
333 lines
10 KiB
Go
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
|
|
}
|