From 4ae7335b31ea19dc117f99f464f0762498b48a16 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 5 Jul 2026 21:45:25 -0400 Subject: [PATCH] feat: import existing smb.conf and /etc/exports on first boot Adds auto-detection of pre-existing Samba shares and NFS exports when nasctl is installed on a host that already has these configs. New package internal/importer parses smb.conf (INI-style) and /etc/exports (line-based) and imports them into SQLite. Imported shares/exports are marked dirty so the user must review and apply manually before any file is overwritten. Backup: before the first Apply, each module backs up the original config to .nasctl.bak. (one time only). New CLI flag --import-on-boot / NASCTL_IMPORT_ON_BOOT env var (default false, opt-in). New API endpoints: GET /api/import/status POST /api/import/samba POST /api/import/nfs New DB methods ReplaceSambaShares/ReplaceNFSExports (transactional replace-all), guarded by import.samba.done / import.nfs.done settings flags. --- AGENTS.md | 1 + README.md | 25 ++++ cmd/nasctl/main.go | 14 +++ internal/db/queries_import.go | 76 ++++++++++++ internal/importer/import.go | 135 +++++++++++++++++++++ internal/importer/nfs.go | 183 ++++++++++++++++++++++++++++ internal/importer/nfs_test.go | 154 ++++++++++++++++++++++++ internal/importer/samba.go | 205 ++++++++++++++++++++++++++++++++ internal/importer/samba_test.go | 166 ++++++++++++++++++++++++++ internal/modules/nfs/nfs.go | 30 +++++ internal/modules/samba/samba.go | 30 +++++ internal/web/auth.go | 6 + internal/web/handlers_import.go | 124 +++++++++++++++++++ internal/web/router.go | 4 + 14 files changed, 1153 insertions(+) create mode 100644 internal/db/queries_import.go create mode 100644 internal/importer/import.go create mode 100644 internal/importer/nfs.go create mode 100644 internal/importer/nfs_test.go create mode 100644 internal/importer/samba.go create mode 100644 internal/importer/samba_test.go create mode 100644 internal/web/handlers_import.go diff --git a/AGENTS.md b/AGENTS.md index 3521fdb..8a48a63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ No separate frontend test command; no lint/typecheck Makefile targets. | `NASCTL_EXPORTS` | `./.data/exports` | `/etc/exports` | | `NASCTL_EXEC_SYSTEM` | `false` | `true` | | `NASCTL_ALLOWED_ROOTS` | (empty) | (empty = any absolute path) | +| `NASCTL_IMPORT_ON_BOOT` | `false` | `true` to import existing `smb.conf` and `/etc/exports` on first boot | ## Frontend Dev diff --git a/README.md b/README.md index e7bc9bb..d10ef1d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Variables de entorno útiles: | `NASCTL_ALLOWED_ROOTS` | (vacío) | Directorios raíz permitidos para paths de shares/exports, separados por coma | | `NASCTL_ADMIN_USER` | `admin` | Usuario admin inicial (solo si no existe ninguno) | | `NASCTL_ADMIN_PASSWORD` | `admin` | Contraseña admin inicial (solo si no existe ninguno) | +| `NASCTL_IMPORT_ON_BOOT` | `false` | `true` para importar `smb.conf` y `/etc/exports` existentes al primer arranque | En desarrollo, `make run` usa `./.data/` y `NASCTL_EXEC_SYSTEM=false` (no toca el sistema real). @@ -66,6 +67,9 @@ El resto de endpoints requieren sesión válida: - `GET/POST/PUT/DELETE /api/samba/shares` — CRUD de shares Samba - `GET/POST/PUT/DELETE /api/nfs/exports` — CRUD de exports NFS - `GET/POST/PUT/DELETE /api/users` — CRUD de usuarios del sistema +- `GET /api/import/status` — estado del import (done, counts) +- `POST /api/import/samba` — re-importa shares desde `smb.conf` +- `POST /api/import/nfs` — re-importa exports desde `/etc/exports` Todos los cambios marcan el módulo como dirty y NO se aplican al sistema hasta `POST /api/apply`. @@ -76,6 +80,27 @@ Todos los cambios marcan el módulo como dirty y NO se aplican al sistema hasta 3. El usuario pulsa "Aplicar cambios" (`POST /api/apply`). 4. El motor regenera archivos de configuración desde templates y recarga servicios. +## Importar configuración existente + +Si el host ya tiene `smb.conf` o `/etc/exports` configurados, nasctl puede importarlos al primer arranque: + +```bash +NASCTL_IMPORT_ON_BOOT=true ./nasctl +``` + +El import: +1. Detecta shares en `smb.conf` y exports en `/etc/exports`. +2. Los importa a SQLite (marca el módulo como dirty). +3. **No sobreescribe** los archivos automáticamente — el usuario revisa y pulsa Apply. +4. Antes del primer Apply, hace backup de los archivos originales en `.nasctl.bak.`. +5. Los flags `import.samba.done`/`import.nfs.done` en la base de datos evitan re-importar en cada arranque. + +Para re-escanear manualmente después de editar los archivos en disco: + +- `POST /api/import/samba` — reimporta shares desde `smb.conf` +- `POST /api/import/nfs` — reimporta exports desde `/etc/exports` +- `GET /api/import/status` — estado del import + ## Empaquetado ```bash diff --git a/cmd/nasctl/main.go b/cmd/nasctl/main.go index 19b244f..ee607a7 100644 --- a/cmd/nasctl/main.go +++ b/cmd/nasctl/main.go @@ -10,6 +10,7 @@ import ( "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/engine" + "github.com/darroyo/nasctl/internal/importer" "github.com/darroyo/nasctl/internal/modules/nfs" "github.com/darroyo/nasctl/internal/modules/samba" "github.com/darroyo/nasctl/internal/modules/users" @@ -25,6 +26,7 @@ func main() { allowedRoots := flag.String("allowed-roots", envOrDefault("NASCTL_ALLOWED_ROOTS", ""), "Comma-separated allowed root dirs for shares/exports (empty = any absolute path)") adminUser := flag.String("admin-user", envOrDefault("NASCTL_ADMIN_USER", "admin"), "Initial admin username (only used if no admin exists)") adminPass := flag.String("admin-pass", envOrDefault("NASCTL_ADMIN_PASSWORD", "admin"), "Initial admin password (only used if no admin exists)") + importOnBoot := flag.Bool("import-on-boot", envOrDefault("NASCTL_IMPORT_ON_BOOT", "false") == "true", "Import existing smb.conf and /etc/exports on first boot") flag.Parse() if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil { @@ -53,6 +55,16 @@ func main() { log.Printf("created initial admin user %q — change the password after first login", *adminUser) } + if *importOnBoot { + result := importer.ImportOnBoot(nil, database, *smbConfPath, *exportsPath) + if result.SambaImported > 0 { + log.Printf("[importer] imported %d existing samba shares — review and apply", result.SambaImported) + } + if result.NFSImported > 0 { + log.Printf("[importer] imported %d existing nfs exports — review and apply", result.NFSImported) + } + } + sambaModule := samba.New(samba.Config{ SMBConfPath: *smbConfPath, Reload: *execSystem, @@ -70,6 +82,8 @@ func main() { srv := web.NewServer(database, eng, web.Options{ AllowedRoots: parseRoots(*allowedRoots), Auth: auth, + SMBConfPath: *smbConfPath, + ExportsPath: *exportsPath, }) log.Printf("nasctl listening on %s (db=%s exec-system=%v)", *addr, *dbPath, *execSystem) diff --git a/internal/db/queries_import.go b/internal/db/queries_import.go new file mode 100644 index 0000000..abe88f1 --- /dev/null +++ b/internal/db/queries_import.go @@ -0,0 +1,76 @@ +package db + +import ( + "fmt" +) + +func (d *DB) ReplaceSambaShares(shares []SambaShare) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM samba_shares`); err != nil { + return fmt.Errorf("clear samba_shares: %w", err) + } + + for _, share := range shares { + validUsers, err := encodeJSONStrings(share.ValidUsers) + if err != nil { + return err + } + validGroups, err := encodeJSONStrings(share.ValidGroups) + if err != nil { + return err + } + readOnly := 0 + if share.ReadOnly { + readOnly = 1 + } + guestOK := 0 + if share.GuestOK { + guestOK = 1 + } + _, err = tx.Exec(` + INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, + ) + if err != nil { + return fmt.Errorf("insert samba share %s: %w", share.Name, err) + } + } + + return tx.Commit() +} + +func (d *DB) ReplaceNFSExports(exports []NFSExport) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM nfs_exports`); err != nil { + return fmt.Errorf("clear nfs_exports: %w", err) + } + + for _, exp := range exports { + clients, err := encodeJSONStrings(exp.Clients) + if err != nil { + return err + } + _, err = tx.Exec(` + INSERT INTO nfs_exports (path, clients, options) + VALUES (?, ?, ?)`, + exp.Path, clients, exp.Options, + ) + if err != nil { + return fmt.Errorf("insert nfs export %s: %w", exp.Path, err) + } + } + + return tx.Commit() +} + diff --git a/internal/importer/import.go b/internal/importer/import.go new file mode 100644 index 0000000..11bbe93 --- /dev/null +++ b/internal/importer/import.go @@ -0,0 +1,135 @@ +package importer + +import ( + "context" + "fmt" + "log" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/modules/nfs" + "github.com/darroyo/nasctl/internal/modules/samba" +) + +type ImportResult struct { + SambaImported int + NFSImported int + SambaSkipped bool + NFSSkipped bool + SambaError string + NFSError string +} + +type ImporterDB interface { + ListSambaShares() ([]db.SambaShare, error) + ListNFSExports() ([]db.NFSExport, error) + ReplaceSambaShares(shares []db.SambaShare) error + ReplaceNFSExports(exports []db.NFSExport) error + GetSetting(key string) (string, bool, error) + SetSetting(key, value string) error + MarkDirty(module string) error +} + +func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath string) ImportResult { + result := ImportResult{} + + sambaDone, _, _ := database.GetSetting("import.samba.done") + nfsDone, _, _ := database.GetSetting("import.nfs.done") + + if sambaDone == "true" && nfsDone == "true" { + return result + } + + if sambaDone != "true" { + sr := importSamba(ctx, database, smbPath) + result.SambaImported = sr.count + result.SambaSkipped = sr.skipped + result.SambaError = sr.err + if sr.err == "" { + _ = database.SetSetting("import.samba.done", "true") + } + } + + if nfsDone != "true" { + nr := importNFS(ctx, database, exportsPath) + result.NFSImported = nr.count + result.NFSSkipped = nr.skipped + result.NFSError = nr.err + if nr.err == "" { + _ = database.SetSetting("import.nfs.done", "true") + } + } + + if result.SambaImported > 0 || result.NFSImported > 0 { + log.Printf("[importer] imported %d samba shares, %d nfs exports", result.SambaImported, result.NFSImported) + } + + return result +} + +func ResetImportFlags(ctx context.Context, database ImporterDB) error { + _ = database.SetSetting("import.samba.done", "") + _ = database.SetSetting("import.nfs.done", "") + return nil +} + +type importStep struct { + count int + skipped bool + err string +} + +func importSamba(ctx context.Context, database ImporterDB, path string) importStep { + shares, err := ImportSambaShares(path) + if err != nil { + return importStep{err: err.Error()} + } + if shares == nil { + return importStep{skipped: true} + } + + existing, err := database.ListSambaShares() + if err != nil { + return importStep{err: err.Error()} + } + if len(existing) > 0 { + return importStep{skipped: true} + } + + if err := database.ReplaceSambaShares(shares); err != nil { + return importStep{err: err.Error()} + } + + if err := database.MarkDirty(samba.ModuleName); err != nil { + return importStep{count: len(shares), err: fmt.Sprintf("imported but could not mark dirty: %v", err)} + } + + return importStep{count: len(shares)} +} + +func importNFS(ctx context.Context, database ImporterDB, path string) importStep { + exports, err := ImportNFSExports(path) + if err != nil { + return importStep{err: err.Error()} + } + if exports == nil { + return importStep{skipped: true} + } + + existing, err := database.ListNFSExports() + if err != nil { + return importStep{err: err.Error()} + } + if len(existing) > 0 { + return importStep{skipped: true} + } + + if err := database.ReplaceNFSExports(exports); err != nil { + return importStep{err: err.Error()} + } + + if err := database.MarkDirty(nfs.ModuleName); err != nil { + return importStep{count: len(exports), err: fmt.Sprintf("imported but could not mark dirty: %v", err)} + } + + return importStep{count: len(exports)} +} diff --git a/internal/importer/nfs.go b/internal/importer/nfs.go new file mode 100644 index 0000000..ce2b5a9 --- /dev/null +++ b/internal/importer/nfs.go @@ -0,0 +1,183 @@ +package importer + +import ( + "bufio" + "bytes" + "fmt" + "os" + "regexp" + "strings" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/system" +) + +var nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:\-/\*@]+$`) + +func ImportNFSExports(path string) ([]db.NFSExport, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read exports: %w", err) + } + + return parseExports(data) +} + +func parseExports(data []byte) ([]db.NFSExport, error) { + var exports []db.NFSExport + scanner := bufio.NewScanner(bytes.NewReader(data)) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + export, ok := parseExportLine(line) + if !ok { + continue + } + + if err := system.ValidatePath(export.Path); err != nil { + continue + } + + validClients := make([]string, 0, len(export.Clients)) + for _, c := range export.Clients { + if err := system.ValidateNFSClient(c); err != nil { + continue + } + validClients = append(validClients, c) + } + if len(validClients) == 0 && len(export.Clients) > 0 { + continue + } + export.Clients = validClients + + if export.Options != "" { + if err := system.ValidateNFSOptions(export.Options); err != nil { + continue + } + } + + exports = append(exports, export) + } + + return exports, nil +} + +func parseExportLine(line string) (db.NFSExport, bool) { + parenDepth := 0 + spaceIdx := -1 + + for i, ch := range line { + switch ch { + case '(': + parenDepth++ + case ')': + parenDepth-- + case ' ': + if parenDepth == 0 && spaceIdx < 0 { + spaceIdx = i + } + } + } + + if spaceIdx < 0 { + return db.NFSExport{}, false + } + + path := strings.TrimSpace(line[:spaceIdx]) + if path == "" { + return db.NFSExport{}, false + } + + rest := strings.TrimSpace(line[spaceIdx:]) + if rest == "" { + return db.NFSExport{}, false + } + + var clients []string + var options string + + parts := splitExportsClients(rest) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + open := strings.IndexByte(part, '(') + close := strings.LastIndexByte(part, ')') + + var client, opts string + if open >= 0 && close > open { + client = strings.TrimSpace(part[:open]) + opts = strings.TrimSpace(part[open+1 : close]) + } else { + client = part + opts = "" + } + + if !nfsClientPattern.MatchString(client) { + continue + } + + clients = append(clients, client) + if opts != "" && options == "" { + options = opts + } + } + + if len(clients) == 0 { + return db.NFSExport{}, false + } + + if options == "" { + options = "rw,sync,no_root_squash" + } + + return db.NFSExport{ + Path: path, + Clients: clients, + Options: options, + }, true +} + +func splitExportsClients(s string) []string { + var result []string + var current []byte + parenDepth := 0 + + for _, ch := range []byte(s) { + switch ch { + case '(': + parenDepth++ + current = append(current, ch) + case ')': + parenDepth-- + current = append(current, ch) + case ' ': + if parenDepth == 0 { + if len(current) > 0 { + result = append(result, string(current)) + current = nil + } + continue + } + current = append(current, ch) + default: + current = append(current, ch) + } + } + + if len(current) > 0 { + result = append(result, string(current)) + } + + return result +} diff --git a/internal/importer/nfs_test.go b/internal/importer/nfs_test.go new file mode 100644 index 0000000..b679753 --- /dev/null +++ b/internal/importer/nfs_test.go @@ -0,0 +1,154 @@ +package importer + +import ( + "testing" +) + +func TestParseExports(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantErr bool + }{ + { + name: "basic export", + input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_subtree_check) +`, + wantLen: 1, + }, + { + name: "multiple clients same path", + input: `/srv/nfs/shared *(ro,sync) 192.168.1.0/24(rw,sync,no_root_squash) +`, + wantLen: 1, + }, + { + name: "multiple exports", + input: `# This is a comment +/srv/nfs/data 192.168.1.0/24(rw,sync) + /srv/nfs/public *(ro,sync) + +# another comment +/srv/nfs/backup 10.0.0.0/8(ro,sync,no_subtree_check) +`, + wantLen: 3, + }, + { + name: "wildcard client", + input: `/srv/nfs/public *(ro,sync,no_root_squash) +`, + wantLen: 1, + }, + { + name: "wildcard client with parentheses", + input: `/srv/nfs/shared *(rw,sync,no_root_squash) +`, + wantLen: 1, + }, + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "only comments", + input: `# comment 1 +# comment 2 +`, + wantLen: 0, + }, + { + name: "multiple spaces between entries", + input: `/srv/nfs/shared 192.168.1.0/24(rw,sync) 10.0.0.0/8(ro) +`, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseExports([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("parseExports() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantLen { + t.Errorf("parseExports() got %d exports, want %d", len(got), tt.wantLen) + for i, e := range got { + t.Logf(" export[%d]: path=%q clients=%v options=%q", i, e.Path, e.Clients, e.Options) + } + } + }) + } +} + +func TestParseExportLine(t *testing.T) { + tests := []struct { + name string + line string + wantPath string + wantCount int + }{ + { + name: "single client with options", + line: `/srv/nfs/shared 192.168.1.100(rw,sync,no_subtree_check)`, + wantPath: `/srv/nfs/shared`, + wantCount: 1, + }, + { + name: "wildcard with default options", + line: `/srv/nfs/public *(ro)`, + wantPath: `/srv/nfs/public`, + wantCount: 1, + }, + { + name: "multiple clients", + line: `/srv/nfs/shared 192.168.1.0/24(rw) 10.0.0.0/8(ro)`, + wantPath: `/srv/nfs/shared`, + wantCount: 2, + }, + { + name: "netgroup", + line: `/srv/nfs/shared @admins(rw,sync)`, + wantPath: `/srv/nfs/shared`, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseExportLine(tt.line) + if !ok { + t.Errorf("parseExportLine(%q) returned false", tt.line) + return + } + if got.Path != tt.wantPath { + t.Errorf("parseExportLine(%q) path = %q, want %q", tt.line, got.Path, tt.wantPath) + } + if len(got.Clients) != tt.wantCount { + t.Errorf("parseExportLine(%q) clients = %v, want %d", tt.line, got.Clients, tt.wantCount) + } + }) + } +} + +func TestSplitExportsClients(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {`192.168.1.0/24(rw,sync) 10.0.0.0/8(ro)`, []string{`192.168.1.0/24(rw,sync)`, `10.0.0.0/8(ro)`}}, + {`*(ro)`, []string{`*(ro)`}}, + {`192.168.1.100(rw)`, []string{`192.168.1.100(rw)`}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := splitExportsClients(tt.input) + if len(got) != len(tt.expected) { + t.Errorf("splitExportsClients(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} diff --git a/internal/importer/samba.go b/internal/importer/samba.go new file mode 100644 index 0000000..b04f2a0 --- /dev/null +++ b/internal/importer/samba.go @@ -0,0 +1,205 @@ +package importer + +import ( + "bufio" + "bytes" + "fmt" + "os" + "strings" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/system" +) + +var ( + sambaIgnoredSections = map[string]bool{ + "global": true, + "printers": true, + "homes": true, + } +) + +type shareBuilder struct { + Name string + Path string + Comment string + ReadOnly bool + GuestOK bool + ValidUsers []string + ValidGroups []string +} + +func ImportSambaShares(path string) ([]db.SambaShare, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read smb.conf: %w", err) + } + + return parseSambaConf(data) +} + +func parseSambaConf(data []byte) ([]db.SambaShare, error) { + var shares []db.SambaShare + var current *shareBuilder + scanner := bufio.NewScanner(bytes.NewReader(data)) + var lineNum int + var continuation string + + for scanner.Scan() { + lineNum++ + raw := scanner.Text() + + if continuation != "" { + continuation += "\n" + raw + if strings.HasSuffix(raw, "\\") { + continuation = strings.TrimSuffix(continuation, "\\") + continue + } + raw = continuation + continuation = "" + } else if strings.HasSuffix(strings.TrimSpace(raw), "\\") { + continuation = strings.TrimSuffix(strings.TrimSpace(raw), "\\") + continue + } + + raw = strings.TrimSpace(raw) + + if raw == "" || startsWith(raw, '#', ';') { + continue + } + + if strings.HasPrefix(raw, "[") { + if current != nil && current.Name != "" && current.Path != "" { + if share := currentToShare(current); share.Name != "" { + shares = append(shares, share) + } + } + sectionName := parseSectionName(raw) + if sectionName == "" || sambaIgnoredSections[strings.ToLower(sectionName)] { + current = nil + continue + } + current = &shareBuilder{Name: sectionName} + continue + } + + if current == nil { + continue + } + + key, value := parseKeyValue(raw) + if key == "" { + continue + } + + switch strings.ToLower(key) { + case "path": + current.Path = value + case "comment": + current.Comment = value + case "read only": + current.ReadOnly = parseBool(value) + case "writable": + current.ReadOnly = !parseBool(value) + case "guest ok": + current.GuestOK = parseBool(value) + case "valid users": + current.ValidUsers = parseCommaList(value) + case "valid groups": + current.ValidGroups = parseCommaList(value) + case "include", "copy", "inherit owner", "inherit permissions", "nt acl support", + "printable", "print mode", "lppause command", "lpresume command", + "queuepause command", "queueresume command": + } + } + + if current != nil && current.Name != "" && current.Path != "" { + if share := currentToShare(current); share.Name != "" { + shares = append(shares, share) + } + } + + return shares, nil +} + +func startsWith(s string, chars ...byte) bool { + for _, c := range chars { + if len(s) > 0 && s[0] == c { + return true + } + } + return false +} + +func parseSectionName(raw string) string { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(raw, "[") || !strings.HasSuffix(raw, "]") { + return "" + } + return strings.Trim(raw, "[]") +} + +func parseKeyValue(raw string) (key, value string) { + eq := strings.IndexByte(raw, '=') + if eq < 0 { + return "", "" + } + key = strings.TrimSpace(raw[:eq]) + value = strings.TrimSpace(raw[eq+1:]) + value = strings.Trim(value, "\"") + + value = strings.TrimPrefix(value, "\"") + value = strings.TrimSuffix(value, "\"") + + return key, value +} + +func parseBool(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + return s == "yes" || s == "true" || s == "1" || s == "on" +} + +func parseCommaList(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func currentToShare(b *shareBuilder) db.SambaShare { + if b.Name == "" || b.Path == "" { + return db.SambaShare{} + } + if err := system.ValidateShareName(b.Name); err != nil { + return db.SambaShare{} + } + if err := system.ValidatePath(b.Path); err != nil { + return db.SambaShare{} + } + for _, u := range b.ValidUsers { + if err := system.ValidateUsername(u); err != nil { + return db.SambaShare{} + } + } + return db.SambaShare{ + Name: b.Name, + Path: b.Path, + Comment: b.Comment, + ReadOnly: b.ReadOnly, + GuestOK: b.GuestOK, + ValidUsers: b.ValidUsers, + ValidGroups: b.ValidGroups, + } +} diff --git a/internal/importer/samba_test.go b/internal/importer/samba_test.go new file mode 100644 index 0000000..badd18b --- /dev/null +++ b/internal/importer/samba_test.go @@ -0,0 +1,166 @@ +package importer + +import ( + "testing" +) + +func TestParseSambaConf(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantErr bool + }{ + { + name: "basic share", + input: `[global] + workgroup = WORKGROUP + security = user + +[share1] + path = /srv/samba/share1 + comment = Test Share + read only = no + guest ok = no +`, + wantLen: 1, + }, + { + name: "multiple shares with valid users and groups", + input: `[global] + server string = Test Server + +[public] + path = /srv/samba/public + comment = Public Files + read only = yes + guest ok = yes + +[data] + path = /srv/samba/data + comment = Data Share + read only = no + writable = yes + guest ok = no + valid users = alice, bob + valid groups = staff +`, + wantLen: 2, + }, + { + name: "ignores global section", + input: `[global] + workgroup = WORKGROUP + security = user + server string = test + +[global] + printing = cups + +[myshare] + path = /srv/samba/myshare +`, + wantLen: 1, + }, + { + name: "ignores printers section", + input: `[printers] + comment = All Printers + path = /var/spool/samba + printable = yes + guest ok = yes + +[global] + workgroup = WORKGROUP + +[myshare] + path = /srv/samba/myshare +`, + wantLen: 1, + }, + { + name: "handles comments with semicolon", + input: `; This is a comment +[global] + workgroup = WORKGROUP + +[myshare] + path = /srv/samba/myshare +`, + wantLen: 1, + }, + { + name: "handles continuation lines", + input: `[myshare] + path = /srv/samba/\ +myshare + comment = continued\ + line +`, + wantLen: 1, + }, + { + name: "handles quoted values", + input: `[myshare] + path = "/srv/samba/myshare" + comment = "Test comment" +`, + wantLen: 1, + }, + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "skip share without path", + input: `[nopath] + comment = No path here +`, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSambaConf([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("parseSambaConf() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantLen { + t.Errorf("parseSambaConf() got %d shares, want %d", len(got), tt.wantLen) + } + }) + } +} + +func TestParseBool(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"yes", true}, + {"no", false}, + {"true", true}, + {"false", false}, + {"1", true}, + {"0", false}, + {"on", true}, + {"off", false}, + {"YES", true}, + {"NO", false}, + {"True", true}, + {"False", false}, + {"", false}, + {"maybe", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := parseBool(tt.input); got != tt.expected { + t.Errorf("parseBool(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} diff --git a/internal/modules/nfs/nfs.go b/internal/modules/nfs/nfs.go index f67dd2a..cd80b83 100644 --- a/internal/modules/nfs/nfs.go +++ b/internal/modules/nfs/nfs.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "text/template" + "time" "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/system" @@ -53,6 +54,10 @@ func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { } 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 @@ -131,6 +136,31 @@ func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) { 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 { diff --git a/internal/modules/samba/samba.go b/internal/modules/samba/samba.go index 4436301..431b41a 100644 --- a/internal/modules/samba/samba.go +++ b/internal/modules/samba/samba.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "text/template" + "time" "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/system" @@ -58,6 +59,10 @@ func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { } func (m *Module) Apply(ctx context.Context, database *db.DB) error { + if err := m.maybeBackup(database); err != nil { + return err + } + shares, err := database.ListSambaShares() if err != nil { return err @@ -121,6 +126,31 @@ func (m *Module) renderConfig(shares []db.SambaShare) ([]byte, error) { return buf.Bytes(), nil } +func (m *Module) maybeBackup(database *db.DB) error { + done, ok, err := database.GetSetting("backup.samba.done") + if err != nil { + return err + } + if ok && done == "true" { + return nil + } + if _, err := os.Stat(m.cfg.SMBConfPath); os.IsNotExist(err) { + return nil + } else if err != nil { + return nil + } + src, err := os.ReadFile(m.cfg.SMBConfPath) + if err != nil { + return nil + } + backupPath := m.cfg.SMBConfPath + ".nasctl.bak." + time.Now().Format("20060102T150405") + if err := os.WriteFile(backupPath, src, 0o644); err != nil { + return fmt.Errorf("backup smb.conf: %w", err) + } + _ = database.SetSetting("backup.samba.done", "true") + return nil +} + func writeAtomic(path string, content []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/web/auth.go b/internal/web/auth.go index 6dcaaca..d89d19d 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -174,11 +174,15 @@ type Server struct { Engine *engine.Engine AllowedRoots []string Auth *AuthService + SMBConfPath string + ExportsPath string } type Options struct { AllowedRoots []string Auth *AuthService + SMBConfPath string + ExportsPath string } func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server { @@ -187,6 +191,8 @@ func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server { Engine: eng, AllowedRoots: opts.AllowedRoots, Auth: opts.Auth, + SMBConfPath: opts.SMBConfPath, + ExportsPath: opts.ExportsPath, } } diff --git a/internal/web/handlers_import.go b/internal/web/handlers_import.go new file mode 100644 index 0000000..b8e6e2b --- /dev/null +++ b/internal/web/handlers_import.go @@ -0,0 +1,124 @@ +package web + +import ( + "net/http" + "time" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/importer" + "github.com/darroyo/nasctl/internal/modules/nfs" + "github.com/darroyo/nasctl/internal/modules/samba" +) + +type importStatus struct { + Samba moduleImportStatus `json:"samba"` + NFS moduleImportStatus `json:"nfs"` +} + +type moduleImportStatus struct { + Done bool `json:"done"` + LastImportAt string `json:"last_import_at,omitempty"` + Count int `json:"count,omitempty"` +} + +func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) { + sambaDone, _, _ := s.DB.GetSetting("import.samba.done") + nfsDone, _, _ := s.DB.GetSetting("import.nfs.done") + + shares, _ := s.DB.ListSambaShares() + exports, _ := s.DB.ListNFSExports() + + status := importStatus{ + Samba: moduleImportStatus{ + Done: sambaDone == "true", + Count: len(shares), + }, + NFS: moduleImportStatus{ + Done: nfsDone == "true", + Count: len(exports), + }, + } + + if ts, ok, _ := s.DB.GetSetting("import.samba.at"); ok { + status.Samba.LastImportAt = ts + } + if ts, ok, _ := s.DB.GetSetting("import.nfs.at"); ok { + status.NFS.LastImportAt = ts + } + + writeJSON(w, http.StatusOK, status) +} + +func (s *Server) handleImportSamba(w http.ResponseWriter, r *http.Request) { + if err := importer.ResetImportFlags(r.Context(), s.DB); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + shares, err := importer.ImportSambaShares(s.SMBConfPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if len(shares) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"imported": 0, "message": "no shares found in smb.conf"}) + return + } + + if err := s.DB.ReplaceSambaShares(shares); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := s.DB.MarkDirty(samba.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + _ = s.DB.SetSetting("import.samba.done", "true") + _ = s.DB.SetSetting("import.samba.at", time.Now().Format(time.RFC3339)) + + writeJSON(w, http.StatusOK, map[string]any{"imported": len(shares)}) +} + +func (s *Server) handleImportNFS(w http.ResponseWriter, r *http.Request) { + if err := importer.ResetImportFlags(r.Context(), s.DB); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + exports, err := importer.ImportNFSExports(s.ExportsPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if len(exports) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"imported": 0, "message": "no exports found in exports file"}) + return + } + + if err := s.DB.ReplaceNFSExports(exports); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := s.DB.MarkDirty(nfs.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + _ = s.DB.SetSetting("import.nfs.done", "true") + _ = s.DB.SetSetting("import.nfs.at", time.Now().Format(time.RFC3339)) + + writeJSON(w, http.StatusOK, map[string]any{"imported": len(exports)}) +} + +type dbExporter interface { + ReplaceSambaShares(shares []db.SambaShare) error + ReplaceNFSExports(exports []db.NFSExport) error + MarkDirty(module string) error +} + +var _ dbExporter = (*db.DB)(nil) diff --git a/internal/web/router.go b/internal/web/router.go index 40cc5b0..284eaaa 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -30,6 +30,10 @@ func NewRouter(s *Server) chi.Router { protected.Get("/apply/log", s.handleApplyLog) protected.Get("/system/status", s.handleSystemStatus) + protected.Get("/import/status", s.handleImportStatus) + protected.Post("/import/samba", s.handleImportSamba) + protected.Post("/import/nfs", s.handleImportNFS) + protected.Route("/samba/shares", func(shares chi.Router) { shares.Get("/", s.handleListSambaShares) shares.Post("/", s.handleCreateSambaShare)