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
+10 -1
View File
@@ -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;
+140 -66
View File
@@ -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<NFSExport> = {
const DEFAULT_EXPORT = {
path: "",
clients: [],
read_only: false,
@@ -30,9 +30,30 @@ function serializeAdvanced(m: Record<string, boolean>): string {
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() {
const [exports, setExports] = useState<NFSExport[]>([]);
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
const [hostDrafts, setHostDrafts] = useState<NFSClient[]>([]);
const [advanced, setAdvanced] = useState<Record<string, boolean>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,26 +68,34 @@ export default function Nfs() {
load();
}, []);
function openEdit(x: Partial<NFSExport>) {
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() {
<tr>
<th className="px-4 py-3">Path</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"></th>
</tr>
@@ -125,8 +168,12 @@ export default function Nfs() {
{exports.map((x) => (
<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 text-slate-300">{x.clients.join(", ") || "*"}</td>
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{flagsSummary(x)}</td>
<td className="px-4 py-3 text-slate-300">
{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">
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
{x.fsid}
@@ -154,11 +201,12 @@ export default function Nfs() {
</div>
{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">
{error && (
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
)}
<div>
<label className="label">Path (absoluto)</label>
<PathField
@@ -166,62 +214,93 @@ export default function Nfs() {
onChange={p => setEditing({ ...editing, path: p })}
/>
</div>
<div>
<label className="label">Clientes / redes (separados por coma)</label>
<input
className="input"
placeholder="192.168.1.0/24, 10.0.0.5"
value={(editing.clients ?? []).join(", ")}
onChange={(e) =>
setEditing({
...editing,
clients: e.target.value
.split(",")
.map((v) => v.trim())
.filter(Boolean),
})
}
/>
<div className="flex items-center justify-between mb-2">
<span className="label">Hosts</span>
<button type="button" className="text-xs text-indigo-400 hover:text-indigo-300" onClick={addHost}>
+ Añadir host
</button>
</div>
<div className="space-y-2">
{hostDrafts.length === 0 && (
<p className="text-sm text-slate-500 py-2">Sin hosts usa "Añadir host" para agregar.</p>
)}
{hostDrafts.map((c, idx) => (
<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 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">
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="checkbox"
checked={!!editing.read_only}
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
/>
<input type="checkbox" className="checkbox" checked={!!editing.read_only} onChange={e => setEditing({ ...editing, read_only: e.target.checked })} />
Read-only
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="checkbox"
checked={!!editing.async}
onChange={(e) => setEditing({ ...editing, async: e.target.checked })}
/>
<input type="checkbox" className="checkbox" checked={!!editing.async} onChange={e => setEditing({ ...editing, async: e.target.checked })} />
Async
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="checkbox"
checked={!!editing.subtree_check}
onChange={(e) => setEditing({ ...editing, subtree_check: e.target.checked })}
/>
Subtree check
<input type="checkbox" className="checkbox" checked={!!editing.root_squash} onChange={e => setEditing({ ...editing, root_squash: e.target.checked })} />
Root squash
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="checkbox"
checked={!!editing.root_squash}
onChange={(e) => setEditing({ ...editing, root_squash: e.target.checked })}
/>
Root squash
<input type="checkbox" className="checkbox" checked={!!editing.subtree_check} onChange={e => setEditing({ ...editing, subtree_check: e.target.checked })} />
Subtree check
</label>
</div>
</div>
@@ -229,19 +308,14 @@ export default function Nfs() {
<details className="group" open={showAdvanced}>
<summary
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>
<div className="mt-2 grid grid-cols-2 gap-2">
{ADVANCED_KEYS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="checkbox"
checked={!!advanced[key]}
onChange={() => toggleAdvanced(key)}
/>
<input type="checkbox" className="checkbox" checked={!!advanced[key]} onChange={() => toggleAdvanced(key)} />
{label}
</label>
))}
@@ -249,7 +323,7 @@ export default function Nfs() {
</details>
<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
</button>
<button className="btn-primary">Guardar</button>