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
+25 -3
View File
@@ -13,7 +13,7 @@ var (
usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`)
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
nfsOptionPattern = regexp.MustCompile(`^[a-z_]+$`)
nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:/\-\*]+$`)
nfsClientPattern = regexp.MustCompile(`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$`)
allowedNFSOptions = map[string]bool{
"rw": true, "ro": true, "sync": true, "async": true,
"root_squash": true, "no_root_squash": true, "all_squash": true,
@@ -108,13 +108,35 @@ func ValidatePathAllowed(path string, allowedRoots []string) error {
}
// ValidateNFSClient checks an NFS client/network specifier.
// Accepts IPv4 addresses (e.g. 192.168.1.20) and IPv4 CIDR (e.g. 192.168.1.0/24).
func ValidateNFSClient(client string) error {
if client == "" || len(client) > 255 {
return fmt.Errorf("invalid nfs client length")
}
if !nfsClientPattern.MatchString(client) {
return fmt.Errorf("invalid nfs client: %q", client)
var ipPart string
var prefix int = 32
if idx := strings.IndexByte(client, '/'); idx >= 0 {
ipPart = client[:idx]
if _, err := fmt.Sscanf(client[idx+1:], "%d", &prefix); err != nil || prefix < 0 || prefix > 32 {
return fmt.Errorf("invalid nfs client CIDR prefix %q (must be 0-32): %q", client[idx+1:], client)
}
} else {
ipPart = client
}
octets := strings.Split(ipPart, ".")
if len(octets) != 4 {
return fmt.Errorf("invalid nfs client %q: must be IPv4 or IPv4/CIDR", client)
}
for _, o := range octets {
var n int
if _, err := fmt.Sscanf(o, "%d", &n); err != nil || n < 0 || n > 255 {
return fmt.Errorf("invalid nfs client %q: octet %q out of range (0-255)", client, o)
}
}
return nil
}