From 512feaffd74dda015c44f7a961340df48bc9f699 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Mon, 6 Jul 2026 11:30:27 -0400 Subject: [PATCH] feat(nfs): per-host NFS options (IP/CIDR with own ro/async/squash flags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 2 +- internal/db/db.go | 86 ++++++++ .../migrations/0005_nfs_per_host_options.sql | 3 + internal/db/models.go | 11 +- internal/db/queries_import.go | 2 +- internal/db/queries_nfs.go | 29 ++- internal/importer/nfs.go | 77 ++++--- internal/importer/nfs_test.go | 43 ++-- internal/modules/nfs/exports.tmpl | 4 +- internal/modules/nfs/nfs.go | 100 ++++++--- internal/modules/nfs/nfs_test.go | 108 +++++++++ internal/system/exec.go | 28 ++- internal/web/handlers_nfs.go | 48 +++- web/src/api.ts | 11 +- web/src/pages/Nfs.tsx | 206 ++++++++++++------ 15 files changed, 585 insertions(+), 173 deletions(-) create mode 100644 internal/db/migrations/0005_nfs_per_host_options.sql create mode 100644 internal/modules/nfs/nfs_test.go diff --git a/Makefile b/Makefile index ef750f5..5d7461e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.4.0 +VERSION?=0.5.0 GO?=go LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/internal/db/db.go b/internal/db/db.go index 45c70f2..eb5aadb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/migrations/0005_nfs_per_host_options.sql b/internal/db/migrations/0005_nfs_per_host_options.sql new file mode 100644 index 0000000..8a6c657 --- /dev/null +++ b/internal/db/migrations/0005_nfs_per_host_options.sql @@ -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. diff --git a/internal/db/models.go b/internal/db/models.go index fc31c12..7c9f6ee 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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"` diff --git a/internal/db/queries_import.go b/internal/db/queries_import.go index b056260..8a179e4 100644 --- a/internal/db/queries_import.go +++ b/internal/db/queries_import.go @@ -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 } diff --git a/internal/db/queries_nfs.go b/internal/db/queries_nfs.go index 52f5a44..96c5f20 100644 --- a/internal/db/queries_nfs.go +++ b/internal/db/queries_nfs.go @@ -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 } diff --git a/internal/importer/nfs.go b/internal/importer/nfs.go index 1f5587e..9969b85 100644 --- a/internal/importer/nfs.go +++ b/internal/importer/nfs.go @@ -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 } diff --git a/internal/importer/nfs_test.go b/internal/importer/nfs_test.go index 1150f21..403a05c 100644 --- a/internal/importer/nfs_test.go +++ b/internal/importer/nfs_test.go @@ -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) + } }) } } diff --git a/internal/modules/nfs/exports.tmpl b/internal/modules/nfs/exports.tmpl index b07b55e..c068a18 100644 --- a/internal/modules/nfs/exports.tmpl +++ b/internal/modules/nfs/exports.tmpl @@ -1,4 +1,4 @@ # Generated by nasctl. Do not edit manually. -{{range .Exports}} -{{.Path}} {{.ClientSpec}} +{{range .Lines}} +{{.}} {{- end}} diff --git a/internal/modules/nfs/nfs.go b/internal/modules/nfs/nfs.go index 992ef96..f725d44 100644 --- a/internal/modules/nfs/nfs.go +++ b/internal/modules/nfs/nfs.go @@ -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 diff --git a/internal/modules/nfs/nfs_test.go b/internal/modules/nfs/nfs_test.go new file mode 100644 index 0000000..c64b30f --- /dev/null +++ b/internal/modules/nfs/nfs_test.go @@ -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) + } +} diff --git a/internal/system/exec.go b/internal/system/exec.go index b5ed1e7..84ddb94 100644 --- a/internal/system/exec.go +++ b/internal/system/exec.go @@ -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 } diff --git a/internal/web/handlers_nfs.go b/internal/web/handlers_nfs.go index d36d955..52d0c1f 100644 --- a/internal/web/handlers_nfs.go +++ b/internal/web/handlers_nfs.go @@ -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 { diff --git a/web/src/api.ts b/web/src/api.ts index 5660834..6b1b19f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -9,10 +9,19 @@ export interface SambaShare { valid_groups: string[]; } +export interface NFSClient { + host: string; + read_only: boolean; + async: boolean; + root_squash: boolean; + subtree_check: boolean; + advanced: string; +} + export interface NFSExport { id: number; path: string; - clients: string[]; + clients: NFSClient[]; read_only: boolean; async: boolean; root_squash: boolean; diff --git a/web/src/pages/Nfs.tsx b/web/src/pages/Nfs.tsx index 26f62b1..f2dad57 100644 --- a/web/src/pages/Nfs.tsx +++ b/web/src/pages/Nfs.tsx @@ -1,10 +1,10 @@ import { FormEvent, useEffect, useState } from "react"; -import { api, NFSExport } from "../api"; +import { api, NFSExport, NFSClient } from "../api"; import { useDirty } from "../DirtyContext"; import Modal from "../components/Modal"; import PathField from "../components/PathField"; -const empty: Partial = { +const DEFAULT_EXPORT = { path: "", clients: [], read_only: false, @@ -30,9 +30,30 @@ function serializeAdvanced(m: Record): string { return JSON.stringify(m); } +function clientDefaults(exp: Partial): Partial { + return { + host: "", + read_only: exp.read_only ?? false, + async: exp.async ?? false, + root_squash: exp.root_squash ?? true, + subtree_check: exp.subtree_check ?? false, + advanced: exp.advanced ?? "{}", + }; +} + +function exportFlagsSummary(x: NFSExport): string { + const parts: string[] = []; + parts.push(x.read_only ? "ro" : "rw"); + parts.push(x.async ? "async" : "sync"); + parts.push(x.subtree_check ? "subtree_check" : "no_subtree_check"); + parts.push(x.root_squash ? "root_squash" : "no_root_squash"); + return parts.join(","); +} + export default function Nfs() { const [exports, setExports] = useState([]); const [editing, setEditing] = useState | null>(null); + const [hostDrafts, setHostDrafts] = useState([]); const [advanced, setAdvanced] = useState>({}); const [showAdvanced, setShowAdvanced] = useState(false); const [error, setError] = useState(null); @@ -47,26 +68,34 @@ export default function Nfs() { load(); }, []); - function openEdit(x: Partial) { + function openEdit(x: NFSExport) { setAdvanced(parseAdvanced(x.advanced ?? "{}")); setShowAdvanced(false); setEditing(x); + setHostDrafts(x.clients.map(c => ({ ...c }))); } function openNew() { setAdvanced({}); setShowAdvanced(false); - setEditing({ ...empty }); + setEditing({ ...DEFAULT_EXPORT }); + setHostDrafts([]); } async function save(e: FormEvent) { e.preventDefault(); if (!editing) return; setError(null); + const payload = { ...editing, + clients: hostDrafts.map(h => ({ + ...h, + advanced: h.advanced !== undefined ? h.advanced : serializeAdvanced(advanced), + })), advanced: serializeAdvanced(advanced), }; + try { if (editing.id) { await api.updateExport(editing.id, payload); @@ -74,6 +103,7 @@ export default function Nfs() { await api.createExport(payload); } setEditing(null); + setHostDrafts([]); await load(); await refresh(); } catch (err) { @@ -92,13 +122,26 @@ export default function Nfs() { setAdvanced(prev => ({ ...prev, [key]: !prev[key] })); } - function flagsSummary(x: NFSExport): string { - const parts: string[] = []; - parts.push(x.read_only ? "ro" : "rw"); - parts.push(x.async ? "async" : "sync"); - parts.push(x.subtree_check ? "subtree_check" : "no_subtree_check"); - parts.push(x.root_squash ? "root_squash" : "no_root_squash"); - return parts.join(","); + function addHost() { + const defaults = clientDefaults(editing ?? {}); + setHostDrafts(prev => [...prev, defaults as NFSClient]); + } + + function removeHost(idx: number) { + setHostDrafts(prev => prev.filter((_, i) => i !== idx)); + } + + function updateHost(idx: number, field: keyof NFSClient, value: string | boolean) { + setHostDrafts(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h)); + } + + function toggleHostAdvanced(idx: number, key: string) { + setHostDrafts(prev => prev.map((h, i) => { + if (i !== idx) return h; + const adv = parseAdvanced(h.advanced || "{}"); + adv[key] = !adv[key]; + return { ...h, advanced: serializeAdvanced(adv) }; + })); } return ( @@ -116,7 +159,7 @@ export default function Nfs() { Path Clientes - Flags + Flags (export) FSID @@ -125,8 +168,12 @@ export default function Nfs() { {exports.map((x) => ( {x.path} - {x.clients.join(", ") || "*"} - {flagsSummary(x)} + + {x.clients.length === 0 + ? "*" + : x.clients.map(c => c.host).join(", ")} + + {exportFlagsSummary(x)} {x.fsid} @@ -154,11 +201,12 @@ export default function Nfs() { {editing && ( - setEditing(null)}> + { setEditing(null); setHostDrafts([]); }}>
{error && (
{error}
)} +
setEditing({ ...editing, path: p })} />
+
- - - setEditing({ - ...editing, - clients: e.target.value - .split(",") - .map((v) => v.trim()) - .filter(Boolean), - }) - } - /> +
+ Hosts + +
+
+ {hostDrafts.length === 0 && ( +

Sin hosts — usa "Añadir host" para agregar.

+ )} + {hostDrafts.map((c, idx) => ( +
+
+ updateHost(idx, "host", e.target.value)} + /> + +
+
+ + + + +
+
+ + {showAdvanced ? "▾" : "▸"} Avanzado + +
+ {ADVANCED_KEYS.map(({ key, label }) => { + const adv = parseAdvanced(c.advanced || "{}"); + return ( + + ); + })} +
+
+
+ ))} +
- Opciones + Opciones por defecto (plantilla para hosts nuevos)
@@ -229,19 +308,14 @@ export default function Nfs() {
{ e.preventDefault(); setShowAdvanced(v => !v); }} + onClick={e => { e.preventDefault(); setShowAdvanced(v => !v); }} > - {showAdvanced ? "▾" : "▸"} Opciones avanzadas + {showAdvanced ? "▾" : "▸"} Opciones avanzadas (plantilla)
{ADVANCED_KEYS.map(({ key, label }) => ( ))} @@ -249,7 +323,7 @@ export default function Nfs() {
-