package nfs import ( "bytes" "context" "embed" "encoding/json" "fmt" "os" "path/filepath" "strings" "text/template" "time" "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/system" ) const ModuleName = "nfs" //go:embed exports.tmpl var exportsTemplate embed.FS type Config struct { ExportsPath string Reload bool } type Module struct { cfg Config } type templateData struct { Lines []string } func New(cfg Config) *Module { if cfg.ExportsPath == "" { cfg.ExportsPath = "/etc/exports" } return &Module{cfg: cfg} } func (m *Module) Name() string { return ModuleName } func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { return database.IsDirty(ModuleName) } func (m *Module) Apply(ctx context.Context, database *db.DB) error { if err := m.maybeBackup(database); err != nil { return err } exports, err := database.ListNFSExports() if err != nil { return err } content, err := m.renderConfig(exports) if err != nil { return err } if err := writeAtomic(m.cfg.ExportsPath, content); err != nil { return err } if m.cfg.Reload { stdout, stderr, err := system.Run(ctx, "exportfs", "-ra") if err != nil { return fmt.Errorf("exportfs -ra: %w (stdout=%q stderr=%q)", err, stdout, stderr) } } message := fmt.Sprintf("applied %d nfs export(s) to %s", len(exports), m.cfg.ExportsPath) if err := database.AppendApplyLog(ModuleName, message, true); err != nil { return err } return database.ClearDirty(ModuleName) } func buildExportFlags(e db.NFSExport) string { parts := make([]string, 0, 8) if e.ReadOnly { parts = append(parts, "ro") } else { parts = append(parts, "rw") } if e.Async { parts = append(parts, "async") } else { parts = append(parts, "sync") } if e.RootSquash { parts = append(parts, "root_squash") } else { parts = append(parts, "no_root_squash") } if e.SubtreeCheck { parts = append(parts, "subtree_check") } else { parts = append(parts, "no_subtree_check") } if e.Advanced != "" && e.Advanced != "{}" { var adv db.NFSAdvanced if err := json.Unmarshal([]byte(e.Advanced), &adv); err == nil { if adv.AllSquash { parts = append(parts, "all_squash") } else { parts = append(parts, "no_all_squash") } if adv.Secure { parts = append(parts, "secure") } else { parts = append(parts, "insecure") } if adv.WDelay { parts = append(parts, "wdelay") } else { parts = append(parts, "no_wdelay") } if adv.Hide { parts = append(parts, "hide") } else { parts = append(parts, "nohide") } if adv.Crossmnt { parts = append(parts, "crossmnt") } } } return strings.Join(parts, ",") } func buildExportSuffix(e db.NFSExport) string { return fmt.Sprintf("fsid=%d", e.FSID) } func clientFlags(c db.NFSClient) string { parts := make([]string, 0, 8) if c.ReadOnly { parts = append(parts, "ro") } else { parts = append(parts, "rw") } if c.Async { parts = append(parts, "async") } else { parts = append(parts, "sync") } if c.RootSquash { parts = append(parts, "root_squash") } else { parts = append(parts, "no_root_squash") } if c.SubtreeCheck { parts = append(parts, "subtree_check") } else { parts = append(parts, "no_subtree_check") } if c.Advanced.AllSquash { parts = append(parts, "all_squash") } else { parts = append(parts, "no_all_squash") } if c.Advanced.Secure { parts = append(parts, "secure") } else { parts = append(parts, "insecure") } if c.Advanced.WDelay { parts = append(parts, "wdelay") } else { parts = append(parts, "no_wdelay") } if c.Advanced.Hide { parts = append(parts, "hide") } else { parts = append(parts, "nohide") } if c.Advanced.Crossmnt { parts = append(parts, "crossmnt") } return strings.Join(parts, ",") } func buildExportLine(e db.NFSExport) string { if len(e.Clients) == 0 { return fmt.Sprintf("%s *(%s) %s", e.Path, buildExportFlags(e), buildExportSuffix(e)) } specs := make([]string, 0, len(e.Clients)) for _, c := range e.Clients { c.Host = strings.TrimSpace(c.Host) if c.Host == "" { continue } specs = append(specs, fmt.Sprintf("%s(%s)", c.Host, clientFlags(c))) } if len(specs) == 0 { return fmt.Sprintf("%s *(%s) %s", e.Path, buildExportFlags(e), buildExportSuffix(e)) } return fmt.Sprintf("%s %s %s", e.Path, strings.Join(specs, " "), buildExportSuffix(e)) } func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) { tmplContent, err := exportsTemplate.ReadFile("exports.tmpl") if err != nil { return nil, fmt.Errorf("read exports template: %w", err) } tmpl, err := template.New("exports").Parse(string(tmplContent)) if err != nil { return nil, fmt.Errorf("parse exports template: %w", err) } data := templateData{Lines: make([]string, 0, len(exports))} for _, export := range exports { data.Lines = append(data.Lines, buildExportLine(export)) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("execute exports template: %w", err) } return buf.Bytes(), nil } func (m *Module) maybeBackup(database *db.DB) error { done, ok, err := database.GetSetting("backup.nfs.done") if err != nil { return err } if ok && done == "true" { return nil } if _, err := os.Stat(m.cfg.ExportsPath); os.IsNotExist(err) { return nil } else if err != nil { return nil } src, err := os.ReadFile(m.cfg.ExportsPath) if err != nil { return nil } backupPath := m.cfg.ExportsPath + ".nasctl.bak." + time.Now().Format("20060102T150405") if err := os.WriteFile(backupPath, src, 0o644); err != nil { return fmt.Errorf("backup exports: %w", err) } _ = database.SetSetting("backup.nfs.done", "true") return nil } func writeAtomic(path string, content []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create config dir: %w", err) } tmp, err := os.CreateTemp(dir, ".exports.*") if err != nil { return fmt.Errorf("create temp file: %w", err) } tmpPath := tmp.Name() cleanup := func() { _ = tmp.Close() _ = os.Remove(tmpPath) } if _, err := tmp.Write(content); err != nil { cleanup() return fmt.Errorf("write temp config: %w", err) } if err := tmp.Chmod(0o644); err != nil { cleanup() return fmt.Errorf("chmod temp config: %w", err) } if err := tmp.Close(); err != nil { cleanup() return fmt.Errorf("close temp config: %w", err) } if err := os.Rename(tmpPath, path); err != nil { cleanup() return fmt.Errorf("rename temp config: %w", err) } return nil }