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:
@@ -1,5 +1,5 @@
|
|||||||
BINARY=nasctl
|
BINARY=nasctl
|
||||||
VERSION?=0.4.0
|
VERSION?=0.5.0
|
||||||
GO?=go
|
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)
|
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
|
BUILD_FLAGS=CGO_ENABLED=0
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -79,6 +80,11 @@ func (d *DB) Migrate() error {
|
|||||||
return fmt.Errorf("populate legacy fsids: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -132,3 +138,83 @@ func isIgnorableMigrationError(err error) bool {
|
|||||||
return strings.Contains(msg, "duplicate column name") ||
|
return strings.Contains(msg, "duplicate column name") ||
|
||||||
strings.Contains(msg, "already exists")
|
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
@@ -37,10 +37,19 @@ type SambaShare struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type NFSExport struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Clients []string `json:"clients"`
|
Clients []NFSClient `json:"clients"`
|
||||||
ReadOnly bool `json:"read_only"`
|
ReadOnly bool `json:"read_only"`
|
||||||
Async bool `json:"async"`
|
Async bool `json:"async"`
|
||||||
RootSquash bool `json:"root_squash"`
|
RootSquash bool `json:"root_squash"`
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (d *DB) ReplaceNFSExports(exports []NFSExport) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, exp := range exports {
|
for _, exp := range exports {
|
||||||
clients, err := encodeJSONStrings(exp.Clients)
|
clients, err := encodeNFSClients(exp.Clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -9,6 +10,28 @@ import (
|
|||||||
"github.com/darroyo/nasctl/internal/system"
|
"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 {
|
func scanNFSExport(row interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (NFSExport, error) {
|
}) (NFSExport, error) {
|
||||||
@@ -31,7 +54,7 @@ func scanNFSExport(row interface {
|
|||||||
return NFSExport{}, err
|
return NFSExport{}, err
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
export.Clients, err = decodeJSONStrings(clients)
|
export.Clients, err = decodeNFSClients(clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, err
|
return NFSExport{}, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +103,7 @@ func (d *DB) GetNFSExport(id int64) (NFSExport, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
|
func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
|
||||||
clients, err := encodeJSONStrings(export.Clients)
|
clients, err := encodeNFSClients(export.Clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, err
|
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) {
|
func (d *DB) UpdateNFSExport(id int64, export NFSExport) (NFSExport, error) {
|
||||||
clients, err := encodeJSONStrings(export.Clients)
|
clients, err := encodeNFSClients(export.Clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, err
|
return NFSExport{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-33
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/darroyo/nasctl/internal/system"
|
"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) {
|
func ImportNFSExports(path string) ([]db.NFSExport, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -47,9 +47,9 @@ func parseExports(data []byte) ([]db.NFSExport, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
validClients := make([]string, 0, len(export.Clients))
|
validClients := make([]db.NFSClient, 0, len(export.Clients))
|
||||||
for _, c := range export.Clients {
|
for _, c := range export.Clients {
|
||||||
if err := system.ValidateNFSClient(c); err != nil {
|
if err := system.ValidateNFSClient(c.Host); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
validClients = append(validClients, c)
|
validClients = append(validClients, c)
|
||||||
@@ -102,8 +102,8 @@ func parseExportLine(line string) (db.NFSExport, bool) {
|
|||||||
return db.NFSExport{}, false
|
return db.NFSExport{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
var clients []string
|
var clients []db.NFSClient
|
||||||
var options string
|
var firstOpts string
|
||||||
|
|
||||||
parts := splitExportsClients(rest)
|
parts := splitExportsClients(rest)
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
@@ -115,54 +115,65 @@ func parseExportLine(line string) (db.NFSExport, bool) {
|
|||||||
open := strings.IndexByte(part, '(')
|
open := strings.IndexByte(part, '(')
|
||||||
close := strings.LastIndexByte(part, ')')
|
close := strings.LastIndexByte(part, ')')
|
||||||
|
|
||||||
var client, opts string
|
var host, opts string
|
||||||
if open >= 0 && close > open {
|
if open >= 0 && close > open {
|
||||||
client = strings.TrimSpace(part[:open])
|
host = strings.TrimSpace(part[:open])
|
||||||
opts = strings.TrimSpace(part[open+1 : close])
|
opts = strings.TrimSpace(part[open+1 : close])
|
||||||
} else {
|
} else {
|
||||||
client = part
|
host = part
|
||||||
opts = ""
|
opts = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if !nfsClientPattern.MatchString(client) {
|
if !nfsClientPattern.MatchString(host) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
clients = append(clients, client)
|
if opts == "" {
|
||||||
if opts != "" && options == "" {
|
opts = firstOpts
|
||||||
options = opts
|
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 {
|
if len(clients) == 0 {
|
||||||
return db.NFSExport{}, false
|
return db.NFSExport{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if options == "" {
|
first := clients[0]
|
||||||
options = "rw,sync,no_root_squash"
|
advJSON, _ := json.Marshal(first.Advanced)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return db.NFSExport{
|
return db.NFSExport{
|
||||||
Path: path,
|
Path: path,
|
||||||
Clients: clients,
|
Clients: clients,
|
||||||
ReadOnly: readOnly,
|
ReadOnly: first.ReadOnly,
|
||||||
Async: async,
|
Async: first.Async,
|
||||||
RootSquash: rootSquash,
|
RootSquash: first.RootSquash,
|
||||||
SubtreeCheck: subtreeCheck,
|
SubtreeCheck: first.SubtreeCheck,
|
||||||
Advanced: string(advJSON),
|
Advanced: string(advJSON),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ func TestParseExports(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic export",
|
name: "basic export single host",
|
||||||
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_subtree_check)
|
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_subtree_check)
|
||||||
`,
|
`,
|
||||||
wantLen: 1,
|
wantLen: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple clients same path",
|
name: "multiple clients per host different options",
|
||||||
input: `/srv/nfs/shared *(ro,sync) 192.168.1.0/24(rw,sync,no_root_squash)
|
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_root_squash) 10.0.0.5(ro,async,root_squash)
|
||||||
`,
|
`,
|
||||||
wantLen: 1,
|
wantLen: 1,
|
||||||
},
|
},
|
||||||
@@ -27,22 +27,16 @@ func TestParseExports(t *testing.T) {
|
|||||||
name: "multiple exports",
|
name: "multiple exports",
|
||||||
input: `# This is a comment
|
input: `# This is a comment
|
||||||
/srv/nfs/data 192.168.1.0/24(rw,sync)
|
/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
|
# 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,
|
wantLen: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wildcard client",
|
name: "single IP host",
|
||||||
input: `/srv/nfs/public *(ro,sync,no_root_squash)
|
input: `/srv/nfs/public 192.168.1.50(ro,sync,no_root_squash)
|
||||||
`,
|
|
||||||
wantLen: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard client with parentheses",
|
|
||||||
input: `/srv/nfs/shared *(rw,sync,no_root_squash)
|
|
||||||
`,
|
`,
|
||||||
wantLen: 1,
|
wantLen: 1,
|
||||||
},
|
},
|
||||||
@@ -61,6 +55,12 @@ func TestParseExports(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "multiple spaces between entries",
|
name: "multiple spaces between entries",
|
||||||
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync) 10.0.0.0/8(ro)
|
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,
|
wantLen: 1,
|
||||||
},
|
},
|
||||||
@@ -97,21 +97,15 @@ func TestParseExportLine(t *testing.T) {
|
|||||||
wantCount: 1,
|
wantCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wildcard with default options",
|
name: "multiple clients different options",
|
||||||
line: `/srv/nfs/public *(ro)`,
|
|
||||||
wantPath: `/srv/nfs/public`,
|
|
||||||
wantCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple clients",
|
|
||||||
line: `/srv/nfs/shared 192.168.1.0/24(rw) 10.0.0.0/8(ro)`,
|
line: `/srv/nfs/shared 192.168.1.0/24(rw) 10.0.0.0/8(ro)`,
|
||||||
wantPath: `/srv/nfs/shared`,
|
wantPath: `/srv/nfs/shared`,
|
||||||
wantCount: 2,
|
wantCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "netgroup",
|
name: "single IP client",
|
||||||
line: `/srv/nfs/shared @admins(rw,sync)`,
|
line: `/srv/nfs/public 192.168.1.50(ro,sync)`,
|
||||||
wantPath: `/srv/nfs/shared`,
|
wantPath: `/srv/nfs/public`,
|
||||||
wantCount: 1,
|
wantCount: 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -129,6 +123,9 @@ func TestParseExportLine(t *testing.T) {
|
|||||||
if len(got.Clients) != tt.wantCount {
|
if len(got.Clients) != tt.wantCount {
|
||||||
t.Errorf("parseExportLine(%q) clients = %v, want %d", tt.line, 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by nasctl. Do not edit manually.
|
# Generated by nasctl. Do not edit manually.
|
||||||
{{range .Exports}}
|
{{range .Lines}}
|
||||||
{{.Path}} {{.ClientSpec}}
|
{{.}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
+71
-29
@@ -30,13 +30,8 @@ type Module struct {
|
|||||||
cfg Config
|
cfg Config
|
||||||
}
|
}
|
||||||
|
|
||||||
type templateExport struct {
|
|
||||||
Path string
|
|
||||||
ClientSpec string
|
|
||||||
}
|
|
||||||
|
|
||||||
type templateData struct {
|
type templateData struct {
|
||||||
Exports []templateExport
|
Lines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Module {
|
func New(cfg Config) *Module {
|
||||||
@@ -87,7 +82,7 @@ func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
|||||||
return database.ClearDirty(ModuleName)
|
return database.ClearDirty(ModuleName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFlags(e db.NFSExport) string {
|
func buildExportFlags(e db.NFSExport) string {
|
||||||
parts := make([]string, 0, 8)
|
parts := make([]string, 0, 8)
|
||||||
if e.ReadOnly {
|
if e.ReadOnly {
|
||||||
parts = append(parts, "ro")
|
parts = append(parts, "ro")
|
||||||
@@ -99,16 +94,16 @@ func buildFlags(e db.NFSExport) string {
|
|||||||
} else {
|
} else {
|
||||||
parts = append(parts, "sync")
|
parts = append(parts, "sync")
|
||||||
}
|
}
|
||||||
if e.SubtreeCheck {
|
|
||||||
parts = append(parts, "subtree_check")
|
|
||||||
} else {
|
|
||||||
parts = append(parts, "no_subtree_check")
|
|
||||||
}
|
|
||||||
if e.RootSquash {
|
if e.RootSquash {
|
||||||
parts = append(parts, "root_squash")
|
parts = append(parts, "root_squash")
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, "no_root_squash")
|
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 != "{}" {
|
if e.Advanced != "" && e.Advanced != "{}" {
|
||||||
var adv db.NFSAdvanced
|
var adv db.NFSAdvanced
|
||||||
if err := json.Unmarshal([]byte(e.Advanced), &adv); err == nil {
|
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, ",")
|
return strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientSpec(clients []string, e db.NFSExport) string {
|
func buildExportSuffix(e db.NFSExport) string {
|
||||||
opts := buildFlags(e)
|
return fmt.Sprintf("fsid=%d", e.FSID)
|
||||||
if len(clients) == 0 {
|
}
|
||||||
return fmt.Sprintf("*(%s)", opts)
|
|
||||||
|
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))
|
if c.Async {
|
||||||
for _, client := range clients {
|
parts = append(parts, "async")
|
||||||
client = strings.TrimSpace(client)
|
} else {
|
||||||
if client == "" {
|
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
|
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 {
|
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) {
|
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)
|
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 {
|
for _, export := range exports {
|
||||||
data.Exports = append(data.Exports, templateExport{
|
data.Lines = append(data.Lines, buildExportLine(export))
|
||||||
Path: export.Path,
|
|
||||||
ClientSpec: clientSpec(export.Clients, export),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -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
@@ -13,7 +13,7 @@ var (
|
|||||||
usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`)
|
usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`)
|
||||||
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
nfsOptionPattern = regexp.MustCompile(`^[a-z_]+$`)
|
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{
|
allowedNFSOptions = map[string]bool{
|
||||||
"rw": true, "ro": true, "sync": true, "async": true,
|
"rw": true, "ro": true, "sync": true, "async": true,
|
||||||
"root_squash": true, "no_root_squash": true, "all_squash": 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.
|
// 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 {
|
func ValidateNFSClient(client string) error {
|
||||||
if client == "" || len(client) > 255 {
|
if client == "" || len(client) > 255 {
|
||||||
return fmt.Errorf("invalid nfs client length")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,24 @@ import (
|
|||||||
"github.com/darroyo/nasctl/internal/system"
|
"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 {
|
type nfsExportRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Clients []string `json:"clients"`
|
Clients []nfsClientRequest `json:"clients"`
|
||||||
// Main options
|
// Main options (used as template defaults for new hosts)
|
||||||
ReadOnly bool `json:"read_only"`
|
ReadOnly bool `json:"read_only"`
|
||||||
Async bool `json:"async"`
|
Async bool `json:"async"`
|
||||||
RootSquash bool `json:"root_squash"`
|
RootSquash bool `json:"root_squash"`
|
||||||
SubtreeCheck bool `json:"subtree_check"`
|
SubtreeCheck bool `json:"subtree_check"`
|
||||||
// Advanced options stored as JSON string
|
Advanced string `json:"advanced"`
|
||||||
Advanced string `json:"advanced"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (req nfsExportRequest) validate(allowedRoots []string) error {
|
func (req nfsExportRequest) validate(allowedRoots []string) error {
|
||||||
@@ -30,7 +38,7 @@ func (req nfsExportRequest) validate(allowedRoots []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, client := range req.Clients {
|
for _, client := range req.Clients {
|
||||||
if err := system.ValidateNFSClient(client); err != nil {
|
if err := system.ValidateNFSClient(client.Host); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,9 +46,20 @@ func (req nfsExportRequest) validate(allowedRoots []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (req nfsExportRequest) toModel() db.NFSExport {
|
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{
|
return db.NFSExport{
|
||||||
Path: req.Path,
|
Path: req.Path,
|
||||||
Clients: req.Clients,
|
Clients: clients,
|
||||||
ReadOnly: req.ReadOnly,
|
ReadOnly: req.ReadOnly,
|
||||||
Async: req.Async,
|
Async: req.Async,
|
||||||
RootSquash: req.RootSquash,
|
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) {
|
func (s *Server) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
|
||||||
exports, err := s.DB.ListNFSExports()
|
exports, err := s.DB.ListNFSExports()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+10
-1
@@ -9,10 +9,19 @@ export interface SambaShare {
|
|||||||
valid_groups: string[];
|
valid_groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NFSClient {
|
||||||
|
host: string;
|
||||||
|
read_only: boolean;
|
||||||
|
async: boolean;
|
||||||
|
root_squash: boolean;
|
||||||
|
subtree_check: boolean;
|
||||||
|
advanced: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NFSExport {
|
export interface NFSExport {
|
||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
clients: string[];
|
clients: NFSClient[];
|
||||||
read_only: boolean;
|
read_only: boolean;
|
||||||
async: boolean;
|
async: boolean;
|
||||||
root_squash: boolean;
|
root_squash: boolean;
|
||||||
|
|||||||
+140
-66
@@ -1,10 +1,10 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { api, NFSExport } from "../api";
|
import { api, NFSExport, NFSClient } from "../api";
|
||||||
import { useDirty } from "../DirtyContext";
|
import { useDirty } from "../DirtyContext";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import PathField from "../components/PathField";
|
import PathField from "../components/PathField";
|
||||||
|
|
||||||
const empty: Partial<NFSExport> = {
|
const DEFAULT_EXPORT = {
|
||||||
path: "",
|
path: "",
|
||||||
clients: [],
|
clients: [],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
@@ -30,9 +30,30 @@ function serializeAdvanced(m: Record<string, boolean>): string {
|
|||||||
return JSON.stringify(m);
|
return JSON.stringify(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clientDefaults(exp: Partial<NFSExport>): Partial<NFSClient> {
|
||||||
|
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() {
|
export default function Nfs() {
|
||||||
const [exports, setExports] = useState<NFSExport[]>([]);
|
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||||
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
||||||
|
const [hostDrafts, setHostDrafts] = useState<NFSClient[]>([]);
|
||||||
const [advanced, setAdvanced] = useState<Record<string, boolean>>({});
|
const [advanced, setAdvanced] = useState<Record<string, boolean>>({});
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -47,26 +68,34 @@ export default function Nfs() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function openEdit(x: Partial<NFSExport>) {
|
function openEdit(x: NFSExport) {
|
||||||
setAdvanced(parseAdvanced(x.advanced ?? "{}"));
|
setAdvanced(parseAdvanced(x.advanced ?? "{}"));
|
||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
setEditing(x);
|
setEditing(x);
|
||||||
|
setHostDrafts(x.clients.map(c => ({ ...c })));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
setAdvanced({});
|
setAdvanced({});
|
||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
setEditing({ ...empty });
|
setEditing({ ...DEFAULT_EXPORT });
|
||||||
|
setHostDrafts([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: FormEvent) {
|
async function save(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...editing,
|
...editing,
|
||||||
|
clients: hostDrafts.map(h => ({
|
||||||
|
...h,
|
||||||
|
advanced: h.advanced !== undefined ? h.advanced : serializeAdvanced(advanced),
|
||||||
|
})),
|
||||||
advanced: serializeAdvanced(advanced),
|
advanced: serializeAdvanced(advanced),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editing.id) {
|
if (editing.id) {
|
||||||
await api.updateExport(editing.id, payload);
|
await api.updateExport(editing.id, payload);
|
||||||
@@ -74,6 +103,7 @@ export default function Nfs() {
|
|||||||
await api.createExport(payload);
|
await api.createExport(payload);
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setHostDrafts([]);
|
||||||
await load();
|
await load();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -92,13 +122,26 @@ export default function Nfs() {
|
|||||||
setAdvanced(prev => ({ ...prev, [key]: !prev[key] }));
|
setAdvanced(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function flagsSummary(x: NFSExport): string {
|
function addHost() {
|
||||||
const parts: string[] = [];
|
const defaults = clientDefaults(editing ?? {});
|
||||||
parts.push(x.read_only ? "ro" : "rw");
|
setHostDrafts(prev => [...prev, defaults as NFSClient]);
|
||||||
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");
|
function removeHost(idx: number) {
|
||||||
return parts.join(",");
|
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 (
|
return (
|
||||||
@@ -116,7 +159,7 @@ export default function Nfs() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">Path</th>
|
<th className="px-4 py-3">Path</th>
|
||||||
<th className="px-4 py-3">Clientes</th>
|
<th className="px-4 py-3">Clientes</th>
|
||||||
<th className="px-4 py-3">Flags</th>
|
<th className="px-4 py-3">Flags (export)</th>
|
||||||
<th className="px-4 py-3">FSID</th>
|
<th className="px-4 py-3">FSID</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -125,8 +168,12 @@ export default function Nfs() {
|
|||||||
{exports.map((x) => (
|
{exports.map((x) => (
|
||||||
<tr key={x.id} className="border-b border-slate-800/60">
|
<tr key={x.id} className="border-b border-slate-800/60">
|
||||||
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
||||||
<td className="px-4 py-3 text-slate-300">{x.clients.join(", ") || "*"}</td>
|
<td className="px-4 py-3 text-slate-300">
|
||||||
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{flagsSummary(x)}</td>
|
{x.clients.length === 0
|
||||||
|
? "*"
|
||||||
|
: x.clients.map(c => c.host).join(", ")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{exportFlagsSummary(x)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
|
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
|
||||||
{x.fsid}
|
{x.fsid}
|
||||||
@@ -154,11 +201,12 @@ export default function Nfs() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => setEditing(null)}>
|
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => { setEditing(null); setHostDrafts([]); }}>
|
||||||
<form onSubmit={save} className="space-y-4">
|
<form onSubmit={save} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Path (absoluto)</label>
|
<label className="label">Path (absoluto)</label>
|
||||||
<PathField
|
<PathField
|
||||||
@@ -166,62 +214,93 @@ export default function Nfs() {
|
|||||||
onChange={p => setEditing({ ...editing, path: p })}
|
onChange={p => setEditing({ ...editing, path: p })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Clientes / redes (separados por coma)</label>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<input
|
<span className="label">Hosts</span>
|
||||||
className="input"
|
<button type="button" className="text-xs text-indigo-400 hover:text-indigo-300" onClick={addHost}>
|
||||||
placeholder="192.168.1.0/24, 10.0.0.5"
|
+ Añadir host
|
||||||
value={(editing.clients ?? []).join(", ")}
|
</button>
|
||||||
onChange={(e) =>
|
</div>
|
||||||
setEditing({
|
<div className="space-y-2">
|
||||||
...editing,
|
{hostDrafts.length === 0 && (
|
||||||
clients: e.target.value
|
<p className="text-sm text-slate-500 py-2">Sin hosts — usa "Añadir host" para agregar.</p>
|
||||||
.split(",")
|
)}
|
||||||
.map((v) => v.trim())
|
{hostDrafts.map((c, idx) => (
|
||||||
.filter(Boolean),
|
<div key={idx} className="rounded border border-slate-700 p-3 space-y-2">
|
||||||
})
|
<div className="flex items-center gap-2">
|
||||||
}
|
<input
|
||||||
/>
|
className="input flex-1"
|
||||||
|
placeholder="192.168.1.20 o 192.168.1.0/24"
|
||||||
|
value={c.host}
|
||||||
|
onChange={e => updateHost(idx, "host", e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn-ghost text-red-400 text-xs px-2" onClick={() => removeHost(idx)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" className="checkbox" checked={c.read_only} onChange={e => updateHost(idx, "read_only", e.target.checked)} />
|
||||||
|
Read-only
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" className="checkbox" checked={c.async} onChange={e => updateHost(idx, "async", e.target.checked)} />
|
||||||
|
Async
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" className="checkbox" checked={c.root_squash} onChange={e => updateHost(idx, "root_squash", e.target.checked)} />
|
||||||
|
Root squash
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" className="checkbox" checked={c.subtree_check} onChange={e => updateHost(idx, "subtree_check", e.target.checked)} />
|
||||||
|
Subtree check
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs text-slate-500 hover:text-slate-300">
|
||||||
|
{showAdvanced ? "▾" : "▸"} Avanzado
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-y-1">
|
||||||
|
{ADVANCED_KEYS.map(({ key, label }) => {
|
||||||
|
const adv = parseAdvanced(c.advanced || "{}");
|
||||||
|
return (
|
||||||
|
<label key={key} className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={!!adv[key]}
|
||||||
|
onChange={() => toggleHostAdvanced(idx, key)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="label">Opciones</span>
|
<span className="label">Opciones por defecto (plantilla para hosts nuevos)</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input type="checkbox" className="checkbox" checked={!!editing.read_only} onChange={e => setEditing({ ...editing, read_only: e.target.checked })} />
|
||||||
type="checkbox"
|
|
||||||
className="checkbox"
|
|
||||||
checked={!!editing.read_only}
|
|
||||||
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Read-only
|
Read-only
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input type="checkbox" className="checkbox" checked={!!editing.async} onChange={e => setEditing({ ...editing, async: e.target.checked })} />
|
||||||
type="checkbox"
|
|
||||||
className="checkbox"
|
|
||||||
checked={!!editing.async}
|
|
||||||
onChange={(e) => setEditing({ ...editing, async: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Async
|
Async
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input type="checkbox" className="checkbox" checked={!!editing.root_squash} onChange={e => setEditing({ ...editing, root_squash: e.target.checked })} />
|
||||||
type="checkbox"
|
Root squash
|
||||||
className="checkbox"
|
|
||||||
checked={!!editing.subtree_check}
|
|
||||||
onChange={(e) => setEditing({ ...editing, subtree_check: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Subtree check
|
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input type="checkbox" className="checkbox" checked={!!editing.subtree_check} onChange={e => setEditing({ ...editing, subtree_check: e.target.checked })} />
|
||||||
type="checkbox"
|
Subtree check
|
||||||
className="checkbox"
|
|
||||||
checked={!!editing.root_squash}
|
|
||||||
onChange={(e) => setEditing({ ...editing, root_squash: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Root squash
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,19 +308,14 @@ export default function Nfs() {
|
|||||||
<details className="group" open={showAdvanced}>
|
<details className="group" open={showAdvanced}>
|
||||||
<summary
|
<summary
|
||||||
className="cursor-pointer text-sm text-slate-400 hover:text-slate-200"
|
className="cursor-pointer text-sm text-slate-400 hover:text-slate-200"
|
||||||
onClick={(e) => { e.preventDefault(); setShowAdvanced(v => !v); }}
|
onClick={e => { e.preventDefault(); setShowAdvanced(v => !v); }}
|
||||||
>
|
>
|
||||||
{showAdvanced ? "▾" : "▸"} Opciones avanzadas
|
{showAdvanced ? "▾" : "▸"} Opciones avanzadas (plantilla)
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
{ADVANCED_KEYS.map(({ key, label }) => (
|
{ADVANCED_KEYS.map(({ key, label }) => (
|
||||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-300">
|
<label key={key} className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input type="checkbox" className="checkbox" checked={!!advanced[key]} onChange={() => toggleAdvanced(key)} />
|
||||||
type="checkbox"
|
|
||||||
className="checkbox"
|
|
||||||
checked={!!advanced[key]}
|
|
||||||
onChange={() => toggleAdvanced(key)}
|
|
||||||
/>
|
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -249,7 +323,7 @@ export default function Nfs() {
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
<button type="button" className="btn-ghost" onClick={() => { setEditing(null); setHostDrafts([]); }}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-primary">Guardar</button>
|
<button className="btn-primary">Guardar</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user