Files
baby-nas/internal/importer/nfs.go
T
darroyo 4ae7335b31 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.
2026-07-05 21:45:25 -04:00

184 lines
3.3 KiB
Go

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
}