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 <path>.nasctl.bak.<timestamp> (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.
This commit is contained in:
2026-07-05 21:45:25 -04:00
parent d6b184d19b
commit 4ae7335b31
14 changed files with 1153 additions and 0 deletions
+205
View File
@@ -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,
}
}