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
+86
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"embed"
"encoding/json"
"fmt"
"io/fs"
"sort"
@@ -79,6 +80,11 @@ func (d *DB) Migrate() error {
return fmt.Errorf("populate legacy fsids: %w", err)
}
}
if name == "0005_nfs_per_host_options.sql" {
if err := d.MigrateNFSClients(); err != nil {
return fmt.Errorf("migrate nfs clients: %w", err)
}
}
}
return nil
}
@@ -132,3 +138,83 @@ func isIgnorableMigrationError(err error) bool {
return strings.Contains(msg, "duplicate column name") ||
strings.Contains(msg, "already exists")
}
func (d *DB) MigrateNFSClients() error {
rows, err := d.conn.Query(`
SELECT id, clients, read_only, async_, root_squash, subtree_check, fsid, advanced
FROM nfs_exports`)
if err != nil {
return fmt.Errorf("query nfs exports: %w", err)
}
defer rows.Close()
type rowData struct {
ID int64
Clients string
ReadOnly bool
Async bool
RootSquash bool
SubtreeCheck bool
FSID int64
Advanced string
}
var toUpdate []rowData
for rows.Next() {
var r rowData
if err := rows.Scan(&r.ID, &r.Clients, &r.ReadOnly, &r.Async, &r.RootSquash, &r.SubtreeCheck, &r.FSID, &r.Advanced); err != nil {
return fmt.Errorf("scan row: %w", err)
}
var raw []any
if err := json.Unmarshal([]byte(r.Clients), &raw); err != nil {
continue
}
if len(raw) == 0 {
continue
}
if _, ok := raw[0].(string); !ok {
continue
}
var adv NFSAdvanced
if r.Advanced != "" && r.Advanced != "{}" {
_ = json.Unmarshal([]byte(r.Advanced), &adv)
}
clients := make([]NFSClient, len(raw))
for i, elem := range raw {
host, ok := elem.(string)
if !ok {
host = ""
}
clients[i] = NFSClient{
Host: host,
ReadOnly: r.ReadOnly,
Async: r.Async,
RootSquash: r.RootSquash,
SubtreeCheck: r.SubtreeCheck,
Advanced: adv,
}
}
clientsJSON, err := json.Marshal(clients)
if err != nil {
return fmt.Errorf("marshal clients for id %d: %w", r.ID, err)
}
r.Clients = string(clientsJSON)
toUpdate = append(toUpdate, r)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("rows iteration: %w", err)
}
for _, r := range toUpdate {
if _, err := d.conn.Exec(`UPDATE nfs_exports SET clients = ? WHERE id = ?`, r.Clients, r.ID); err != nil {
return fmt.Errorf("update nfs export id %d: %w", r.ID, err)
}
}
return nil
}
@@ -0,0 +1,3 @@
-- Migrate nfs_exports.clients from []string to []NFSClient (per-host options).
-- Schema change: none (clients column already TEXT/JSON).
-- Data transformation is done by the Go hook MigrateNFSClients in db.go.
+10 -1
View File
@@ -37,10 +37,19 @@ type SambaShare struct {
UpdatedAt time.Time `json:"updated_at"`
}
type NFSClient struct {
Host string `json:"host"`
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
SubtreeCheck bool `json:"subtree_check"`
Advanced NFSAdvanced `json:"advanced"`
}
type NFSExport struct {
ID int64 `json:"id"`
Path string `json:"path"`
Clients []string `json:"clients"`
Clients []NFSClient `json:"clients"`
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
+1 -1
View File
@@ -57,7 +57,7 @@ func (d *DB) ReplaceNFSExports(exports []NFSExport) error {
}
for _, exp := range exports {
clients, err := encodeJSONStrings(exp.Clients)
clients, err := encodeNFSClients(exp.Clients)
if err != nil {
return err
}
+26 -3
View File
@@ -2,6 +2,7 @@ package db
import (
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strconv"
@@ -9,6 +10,28 @@ import (
"github.com/darroyo/nasctl/internal/system"
)
func encodeNFSClients(clients []NFSClient) (string, error) {
if clients == nil {
clients = []NFSClient{}
}
data, err := json.Marshal(clients)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeNFSClients(raw string) ([]NFSClient, error) {
if raw == "" {
return []NFSClient{}, nil
}
var clients []NFSClient
if err := json.Unmarshal([]byte(raw), &clients); err != nil {
return nil, err
}
return clients, nil
}
func scanNFSExport(row interface {
Scan(dest ...any) error
}) (NFSExport, error) {
@@ -31,7 +54,7 @@ func scanNFSExport(row interface {
return NFSExport{}, err
}
var err error
export.Clients, err = decodeJSONStrings(clients)
export.Clients, err = decodeNFSClients(clients)
if err != nil {
return NFSExport{}, err
}
@@ -80,7 +103,7 @@ func (d *DB) GetNFSExport(id int64) (NFSExport, error) {
}
func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
clients, err := encodeJSONStrings(export.Clients)
clients, err := encodeNFSClients(export.Clients)
if err != nil {
return NFSExport{}, err
}
@@ -120,7 +143,7 @@ func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
}
func (d *DB) UpdateNFSExport(id int64, export NFSExport) (NFSExport, error) {
clients, err := encodeJSONStrings(export.Clients)
clients, err := encodeNFSClients(export.Clients)
if err != nil {
return NFSExport{}, err
}
+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)
}
})
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
# Generated by nasctl. Do not edit manually.
{{range .Exports}}
{{.Path}} {{.ClientSpec}}
{{range .Lines}}
{{.}}
{{- end}}
+71 -29
View File
@@ -30,13 +30,8 @@ type Module struct {
cfg Config
}
type templateExport struct {
Path string
ClientSpec string
}
type templateData struct {
Exports []templateExport
Lines []string
}
func New(cfg Config) *Module {
@@ -87,7 +82,7 @@ func (m *Module) Apply(ctx context.Context, database *db.DB) error {
return database.ClearDirty(ModuleName)
}
func buildFlags(e db.NFSExport) string {
func buildExportFlags(e db.NFSExport) string {
parts := make([]string, 0, 8)
if e.ReadOnly {
parts = append(parts, "ro")
@@ -99,16 +94,16 @@ func buildFlags(e db.NFSExport) string {
} else {
parts = append(parts, "sync")
}
if e.SubtreeCheck {
parts = append(parts, "subtree_check")
} else {
parts = append(parts, "no_subtree_check")
}
if e.RootSquash {
parts = append(parts, "root_squash")
} else {
parts = append(parts, "no_root_squash")
}
if e.SubtreeCheck {
parts = append(parts, "subtree_check")
} else {
parts = append(parts, "no_subtree_check")
}
if e.Advanced != "" && e.Advanced != "{}" {
var adv db.NFSAdvanced
if err := json.Unmarshal([]byte(e.Advanced), &adv); err == nil {
@@ -137,27 +132,77 @@ func buildFlags(e db.NFSExport) string {
}
}
}
parts = append(parts, fmt.Sprintf("fsid=%d", e.FSID))
return strings.Join(parts, ",")
}
func clientSpec(clients []string, e db.NFSExport) string {
opts := buildFlags(e)
if len(clients) == 0 {
return fmt.Sprintf("*(%s)", opts)
func buildExportSuffix(e db.NFSExport) string {
return fmt.Sprintf("fsid=%d", e.FSID)
}
func clientFlags(c db.NFSClient) string {
parts := make([]string, 0, 8)
if c.ReadOnly {
parts = append(parts, "ro")
} else {
parts = append(parts, "rw")
}
specs := make([]string, 0, len(clients))
for _, client := range clients {
client = strings.TrimSpace(client)
if client == "" {
if c.Async {
parts = append(parts, "async")
} else {
parts = append(parts, "sync")
}
if c.RootSquash {
parts = append(parts, "root_squash")
} else {
parts = append(parts, "no_root_squash")
}
if c.SubtreeCheck {
parts = append(parts, "subtree_check")
} else {
parts = append(parts, "no_subtree_check")
}
if c.Advanced.AllSquash {
parts = append(parts, "all_squash")
} else {
parts = append(parts, "no_all_squash")
}
if c.Advanced.Secure {
parts = append(parts, "secure")
} else {
parts = append(parts, "insecure")
}
if c.Advanced.WDelay {
parts = append(parts, "wdelay")
} else {
parts = append(parts, "no_wdelay")
}
if c.Advanced.Hide {
parts = append(parts, "hide")
} else {
parts = append(parts, "nohide")
}
if c.Advanced.Crossmnt {
parts = append(parts, "crossmnt")
}
return strings.Join(parts, ",")
}
func buildExportLine(e db.NFSExport) string {
if len(e.Clients) == 0 {
return fmt.Sprintf("%s *(%s) %s", e.Path, buildExportFlags(e), buildExportSuffix(e))
}
specs := make([]string, 0, len(e.Clients))
for _, c := range e.Clients {
c.Host = strings.TrimSpace(c.Host)
if c.Host == "" {
continue
}
specs = append(specs, fmt.Sprintf("%s(%s)", client, opts))
specs = append(specs, fmt.Sprintf("%s(%s)", c.Host, clientFlags(c)))
}
if len(specs) == 0 {
return fmt.Sprintf("*(%s)", opts)
return fmt.Sprintf("%s *(%s) %s", e.Path, buildExportFlags(e), buildExportSuffix(e))
}
return strings.Join(specs, " ")
return fmt.Sprintf("%s %s %s", e.Path, strings.Join(specs, " "), buildExportSuffix(e))
}
func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) {
@@ -171,12 +216,9 @@ func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) {
return nil, fmt.Errorf("parse exports template: %w", err)
}
data := templateData{Exports: make([]templateExport, 0, len(exports))}
data := templateData{Lines: make([]string, 0, len(exports))}
for _, export := range exports {
data.Exports = append(data.Exports, templateExport{
Path: export.Path,
ClientSpec: clientSpec(export.Clients, export),
})
data.Lines = append(data.Lines, buildExportLine(export))
}
var buf bytes.Buffer
+108
View File
@@ -0,0 +1,108 @@
package nfs
import (
"testing"
"github.com/darroyo/nasctl/internal/db"
)
func TestBuildExportLine(t *testing.T) {
tests := []struct {
name string
exp db.NFSExport
lines []string
}{
{
name: "single host with options",
exp: db.NFSExport{
Path: "/srv/nfs/shared",
Clients: []db.NFSClient{{Host: "192.168.1.100", ReadOnly: false, Async: false, RootSquash: true, SubtreeCheck: false}},
ReadOnly: false,
Async: false,
RootSquash: true,
SubtreeCheck: false,
FSID: 1,
Advanced: "{}",
},
lines: []string{`/srv/nfs/shared 192.168.1.100(rw,sync,root_squash,no_subtree_check,no_all_squash,insecure,no_wdelay,nohide) fsid=1`},
},
{
name: "multiple hosts different options",
exp: db.NFSExport{
Path: "/srv/nfs/shared",
Clients: []db.NFSClient{
{Host: "192.168.1.0/24", ReadOnly: false, Async: false, RootSquash: true, SubtreeCheck: false, Advanced: db.NFSAdvanced{Crossmnt: true}},
{Host: "10.0.0.5", ReadOnly: true, Async: true, RootSquash: false, SubtreeCheck: false},
},
ReadOnly: false,
Async: false,
RootSquash: true,
SubtreeCheck: false,
FSID: 2,
Advanced: "{}",
},
lines: []string{
`/srv/nfs/shared 192.168.1.0/24(rw,sync,root_squash,no_subtree_check,no_all_squash,insecure,no_wdelay,nohide,crossmnt) 10.0.0.5(ro,async,no_root_squash,no_subtree_check,no_all_squash,insecure,no_wdelay,nohide) fsid=2`,
},
},
{
name: "no clients fallback wildcard",
exp: db.NFSExport{
Path: "/srv/nfs/public",
Clients: []db.NFSClient{},
ReadOnly: true,
Async: false,
RootSquash: true,
SubtreeCheck: false,
FSID: 3,
Advanced: "{}",
},
lines: []string{`/srv/nfs/public *(ro,sync,root_squash,no_subtree_check) fsid=3`},
},
{
name: "advanced options per host",
exp: db.NFSExport{
Path: "/srv/nfs/secure",
Clients: []db.NFSClient{
{Host: "192.168.1.0/24", ReadOnly: false, Async: false, RootSquash: true, SubtreeCheck: false, Advanced: db.NFSAdvanced{AllSquash: true, Secure: true, WDelay: true, Hide: true, Crossmnt: true}},
},
ReadOnly: false,
Async: false,
RootSquash: true,
SubtreeCheck: false,
FSID: 4,
Advanced: "{}",
},
lines: []string{
`/srv/nfs/secure 192.168.1.0/24(rw,sync,root_squash,no_subtree_check,all_squash,secure,wdelay,hide,crossmnt) fsid=4`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
line := buildExportLine(tt.exp)
if len(tt.lines) != 1 {
t.Fatalf("expected 1 line, got test setup error")
}
if line != tt.lines[0] {
t.Errorf("buildExportLine() = %q, want %q", line, tt.lines[0])
}
})
}
}
func TestClientFlags(t *testing.T) {
c := db.NFSClient{
Host: "192.168.1.0/24",
ReadOnly: true,
Async: true,
RootSquash: false,
SubtreeCheck: true,
Advanced: db.NFSAdvanced{AllSquash: true, Secure: true},
}
flags := clientFlags(c)
if flags != "ro,async,no_root_squash,subtree_check,all_squash,secure,no_wdelay,nohide" {
t.Errorf("clientFlags() = %q, unexpected flags", flags)
}
}
+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
}
+38 -10
View File
@@ -13,16 +13,24 @@ import (
"github.com/darroyo/nasctl/internal/system"
)
type nfsClientRequest struct {
Host string `json:"host"`
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
SubtreeCheck bool `json:"subtree_check"`
Advanced string `json:"advanced"`
}
type nfsExportRequest struct {
Path string `json:"path"`
Clients []string `json:"clients"`
// Main options
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
SubtreeCheck bool `json:"subtree_check"`
// Advanced options stored as JSON string
Advanced string `json:"advanced"`
Clients []nfsClientRequest `json:"clients"`
// Main options (used as template defaults for new hosts)
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
SubtreeCheck bool `json:"subtree_check"`
Advanced string `json:"advanced"`
}
func (req nfsExportRequest) validate(allowedRoots []string) error {
@@ -30,7 +38,7 @@ func (req nfsExportRequest) validate(allowedRoots []string) error {
return err
}
for _, client := range req.Clients {
if err := system.ValidateNFSClient(client); err != nil {
if err := system.ValidateNFSClient(client.Host); err != nil {
return err
}
}
@@ -38,9 +46,20 @@ func (req nfsExportRequest) validate(allowedRoots []string) error {
}
func (req nfsExportRequest) toModel() db.NFSExport {
clients := make([]db.NFSClient, 0, len(req.Clients))
for _, c := range req.Clients {
clients = append(clients, db.NFSClient{
Host: c.Host,
ReadOnly: c.ReadOnly,
Async: c.Async,
RootSquash: c.RootSquash,
SubtreeCheck: c.SubtreeCheck,
Advanced: parseNFSAdvanced(c.Advanced),
})
}
return db.NFSExport{
Path: req.Path,
Clients: req.Clients,
Clients: clients,
ReadOnly: req.ReadOnly,
Async: req.Async,
RootSquash: req.RootSquash,
@@ -49,6 +68,15 @@ func (req nfsExportRequest) toModel() db.NFSExport {
}
}
func parseNFSAdvanced(raw string) db.NFSAdvanced {
var adv db.NFSAdvanced
if raw == "" || raw == "{}" {
return adv
}
_ = json.Unmarshal([]byte(raw), &adv)
return adv
}
func (s *Server) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
exports, err := s.DB.ListNFSExports()
if err != nil {