feat(nfs): per-host NFS options (IP/CIDR with own ro/async/squash flags)

This is a backward-compatible MINOR bump (0.4.0 → 0.5.0).

BREAKING NOTES (for users upgrading from pre-0.5.0):
- The nfs_exports.clients column schema changed from []string to
  []NFSClient (per-host options). A migration (0005) transforms existing
  string arrays into object arrays, taking export-level options as
  defaults for each host.
- ValidateNFSClient now only accepts IPv4 (192.168.1.1) or IPv4/CIDR
  (192.168.1.0/24). Hostnames, wildcards, netgroups are rejected.
- If you use NASCTL_IMPORT_ON_BOOT, re-import your /etc/exports to pick
  up per-host options.

What changed:
- NFSClient type: {host, read_only, async, root_squash, subtree_check, advanced}
- NFSExport.Clients is now []NFSClient (was []string)
- export-level flags (ro/async/root_squash/subtree_check/advanced) are
  preserved as template defaults for newly added hosts in the UI.
- buildExportLine generates: path host1(ro,sync,...) host2(rw,async,...) fsid=N
- ValidateNFSClient: strict IPv4/CIDR only (0-255 octets, /0-32 prefix)
- parseExportLine now parses per-host options from /etc/exports (previously
  only the first host's options were kept, others were discarded)
- UI: per-host rows with toggles (ro/async/root_squash/subtree_check) and
  advanced options (all_squash, secure, wdelay, hide, crossmnt)
This commit is contained in:
2026-07-06 11:30:27 -04:00
parent 0a4004a9ab
commit 512feaffd7
15 changed files with 585 additions and 173 deletions
+44 -33
View File
@@ -13,7 +13,7 @@ import (
"github.com/darroyo/nasctl/internal/system"
)
var nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:\-/\*@]+$`)
var nfsClientPattern = regexp.MustCompile(`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$`)
func ImportNFSExports(path string) ([]db.NFSExport, error) {
data, err := os.ReadFile(path)
@@ -47,9 +47,9 @@ func parseExports(data []byte) ([]db.NFSExport, error) {
continue
}
validClients := make([]string, 0, len(export.Clients))
validClients := make([]db.NFSClient, 0, len(export.Clients))
for _, c := range export.Clients {
if err := system.ValidateNFSClient(c); err != nil {
if err := system.ValidateNFSClient(c.Host); err != nil {
continue
}
validClients = append(validClients, c)
@@ -102,8 +102,8 @@ func parseExportLine(line string) (db.NFSExport, bool) {
return db.NFSExport{}, false
}
var clients []string
var options string
var clients []db.NFSClient
var firstOpts string
parts := splitExportsClients(rest)
for _, part := range parts {
@@ -115,54 +115,65 @@ func parseExportLine(line string) (db.NFSExport, bool) {
open := strings.IndexByte(part, '(')
close := strings.LastIndexByte(part, ')')
var client, opts string
var host, opts string
if open >= 0 && close > open {
client = strings.TrimSpace(part[:open])
host = strings.TrimSpace(part[:open])
opts = strings.TrimSpace(part[open+1 : close])
} else {
client = part
host = part
opts = ""
}
if !nfsClientPattern.MatchString(client) {
if !nfsClientPattern.MatchString(host) {
continue
}
clients = append(clients, client)
if opts != "" && options == "" {
options = opts
if opts == "" {
opts = firstOpts
if opts == "" {
opts = "rw,sync,no_root_squash"
}
} else if firstOpts == "" {
firstOpts = opts
}
readOnly := strings.Contains(opts, "ro")
async := strings.Contains(opts, "async")
rootSquash := !strings.Contains(opts, "no_root_squash")
subtreeCheck := strings.Contains(opts, "subtree_check")
adv := db.NFSAdvanced{
AllSquash: strings.Contains(opts, "all_squash"),
Secure: strings.Contains(opts, "secure"),
WDelay: strings.Contains(opts, "wdelay"),
Hide: strings.Contains(opts, "hide"),
Crossmnt: strings.Contains(opts, "crossmnt"),
}
clients = append(clients, db.NFSClient{
Host: host,
ReadOnly: readOnly,
Async: async,
RootSquash: rootSquash,
SubtreeCheck: subtreeCheck,
Advanced: adv,
})
}
if len(clients) == 0 {
return db.NFSExport{}, false
}
if options == "" {
options = "rw,sync,no_root_squash"
}
readOnly := strings.Contains(options, "ro")
async := strings.Contains(options, "async")
rootSquash := !strings.Contains(options, "no_root_squash")
subtreeCheck := strings.Contains(options, "subtree_check")
adv := db.NFSAdvanced{
AllSquash: strings.Contains(options, "all_squash"),
Secure: strings.Contains(options, "secure"),
WDelay: strings.Contains(options, "wdelay"),
Hide: strings.Contains(options, "hide"),
Crossmnt: strings.Contains(options, "crossmnt"),
}
advJSON, _ := json.Marshal(adv)
first := clients[0]
advJSON, _ := json.Marshal(first.Advanced)
return db.NFSExport{
Path: path,
Clients: clients,
ReadOnly: readOnly,
Async: async,
RootSquash: rootSquash,
SubtreeCheck: subtreeCheck,
ReadOnly: first.ReadOnly,
Async: first.Async,
RootSquash: first.RootSquash,
SubtreeCheck: first.SubtreeCheck,
Advanced: string(advJSON),
}, true
}
+20 -23
View File
@@ -12,14 +12,14 @@ func TestParseExports(t *testing.T) {
wantErr bool
}{
{
name: "basic export",
name: "basic export single host",
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)
name: "multiple clients per host different options",
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_root_squash) 10.0.0.5(ro,async,root_squash)
`,
wantLen: 1,
},
@@ -27,22 +27,16 @@ func TestParseExports(t *testing.T) {
name: "multiple exports",
input: `# This is a comment
/srv/nfs/data 192.168.1.0/24(rw,sync)
/srv/nfs/public *(ro,sync)
/srv/nfs/public 10.0.0.0/8(ro,sync)
# another comment
/srv/nfs/backup 10.0.0.0/8(ro,sync,no_subtree_check)
/srv/nfs/backup 172.16.0.0/12(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)
name: "single IP host",
input: `/srv/nfs/public 192.168.1.50(ro,sync,no_root_squash)
`,
wantLen: 1,
},
@@ -61,6 +55,12 @@ func TestParseExports(t *testing.T) {
{
name: "multiple spaces between entries",
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync) 10.0.0.0/8(ro)
`,
wantLen: 1,
},
{
name: "client without options inherits from first",
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_root_squash) 10.0.0.5
`,
wantLen: 1,
},
@@ -97,21 +97,15 @@ func TestParseExportLine(t *testing.T) {
wantCount: 1,
},
{
name: "wildcard with default options",
line: `/srv/nfs/public *(ro)`,
wantPath: `/srv/nfs/public`,
wantCount: 1,
},
{
name: "multiple clients",
name: "multiple clients different options",
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`,
name: "single IP client",
line: `/srv/nfs/public 192.168.1.50(ro,sync)`,
wantPath: `/srv/nfs/public`,
wantCount: 1,
},
}
@@ -129,6 +123,9 @@ func TestParseExportLine(t *testing.T) {
if len(got.Clients) != tt.wantCount {
t.Errorf("parseExportLine(%q) clients = %v, want %d", tt.line, got.Clients, tt.wantCount)
}
for i, c := range got.Clients {
t.Logf(" client[%d]: host=%q ro=%v async=%v root_squash=%v subtree_check=%v", i, c.Host, c.ReadOnly, c.Async, c.RootSquash, c.SubtreeCheck)
}
})
}
}