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
+135
View File
@@ -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)}
}
+183
View File
@@ -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
}
+154
View File
@@ -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)
}
})
}
}
+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,
}
}
+166
View File
@@ -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)
}
})
}
}