feat: structured NFS options with auto-fsid and manual watched mounts

NFS exports: replace plain-text options string with typed booleans
(read_only, async, root_squash, subtree_check) + advanced JSON blob.
fsid is auto-generated via crypto/rand with collision retry and is
never user-settable. Breaking API change (options field removed).

Dashboard: filter disks to manual+samba+nfs sources only; no more
auto-discovery of all /proc/mounts entries.

Settings: new 'Puntos de montaje vigilados' card with add/remove
for manual mount points. AllowedRoots validation applied.

Version bump: 0.1.10 -> 0.2.0
This commit is contained in:
2026-07-06 00:23:49 -04:00
parent 63e0b5146a
commit e3add68584
9 changed files with 280 additions and 60 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=nasctl BINARY=nasctl
VERSION?=0.1.10 VERSION?=0.2.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
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS watched_mounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+6
View File
@@ -59,6 +59,12 @@ type NFSAdvanced struct {
Crossmnt bool `json:"crossmnt"` Crossmnt bool `json:"crossmnt"`
} }
type WatchedMount struct {
ID int64 `json:"id"`
Path string `json:"path"`
CreatedAt time.Time `json:"created_at"`
}
type DirtyModule struct { type DirtyModule struct {
Module string `json:"module"` Module string `json:"module"`
MarkedAt time.Time `json:"marked_at"` MarkedAt time.Time `json:"marked_at"`
+87
View File
@@ -0,0 +1,87 @@
package db
import (
"database/sql"
"fmt"
)
func scanWatchedMount(row interface {
Scan(dest ...any) error
}) (WatchedMount, error) {
var m WatchedMount
var createdAt string
if err := row.Scan(&m.ID, &m.Path, &createdAt); err != nil {
return WatchedMount{}, err
}
m.CreatedAt = parseTime(createdAt)
return m, nil
}
func (d *DB) ListWatchedMounts() ([]WatchedMount, error) {
rows, err := d.conn.Query(`SELECT id, path, created_at FROM watched_mounts ORDER BY path ASC`)
if err != nil {
return nil, fmt.Errorf("list watched mounts: %w", err)
}
defer rows.Close()
var mounts []WatchedMount
for rows.Next() {
m, err := scanWatchedMount(rows)
if err != nil {
return nil, fmt.Errorf("scan watched mount: %w", err)
}
mounts = append(mounts, m)
}
return mounts, rows.Err()
}
func (d *DB) GetWatchedMount(id int64) (WatchedMount, error) {
row := d.conn.QueryRow(`SELECT id, path, created_at FROM watched_mounts WHERE id = ?`, id)
m, err := scanWatchedMount(row)
if err == sql.ErrNoRows {
return WatchedMount{}, fmt.Errorf("watched mount not found")
}
if err != nil {
return WatchedMount{}, fmt.Errorf("get watched mount: %w", err)
}
return m, nil
}
func (d *DB) CreateWatchedMount(path string) (WatchedMount, error) {
result, err := d.conn.Exec(
`INSERT INTO watched_mounts (path) VALUES (?)`,
path,
)
if err != nil {
return WatchedMount{}, fmt.Errorf("create watched mount: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return WatchedMount{}, fmt.Errorf("last insert id: %w", err)
}
return d.GetWatchedMount(id)
}
func (d *DB) DeleteWatchedMount(id int64) error {
result, err := d.conn.Exec(`DELETE FROM watched_mounts WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete watched mount: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("watched mount not found")
}
return nil
}
func (d *DB) WatchedMountPathExists(path string) (bool, error) {
var count int
err := d.conn.QueryRow(`SELECT COUNT(1) FROM watched_mounts WHERE path = ?`, path).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
+64 -25
View File
@@ -1,13 +1,14 @@
package web package web
import ( import (
"bufio" "encoding/json"
"net/http" "net/http"
"os"
"sort" "sort"
"strings" "strings"
"syscall" "syscall"
"github.com/go-chi/chi/v5"
"github.com/darroyo/nasctl/internal/system" "github.com/darroyo/nasctl/internal/system"
) )
@@ -47,8 +48,6 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
func (s *Server) collectDiskUsage() []diskUsage { func (s *Server) collectDiskUsage() []diskUsage {
entries := make(map[string]*diskUsage) entries := make(map[string]*diskUsage)
entries["/"] = &diskUsage{Path: "/", Source: "system", Available: true}
shares, err := s.DB.ListSambaShares() shares, err := s.DB.ListSambaShares()
if err == nil { if err == nil {
for _, share := range shares { for _, share := range shares {
@@ -74,7 +73,15 @@ func (s *Server) collectDiskUsage() []diskUsage {
} }
} }
readProcMounts(entries) watched, err := s.DB.ListWatchedMounts()
if err == nil {
for _, m := range watched {
key := m.Path
if _, ok := entries[key]; !ok {
entries[key] = &diskUsage{Path: key, Source: "manual", Available: true}
}
}
}
var result []diskUsage var result []diskUsage
for _, e := range entries { for _, e := range entries {
@@ -92,26 +99,6 @@ func (s *Server) collectDiskUsage() []diskUsage {
return result return result
} }
func readProcMounts(entries map[string]*diskUsage) {
f, err := os.Open("/proc/mounts")
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
mountPoint := fields[1]
if _, ok := entries[mountPoint]; !ok {
entries[mountPoint] = &diskUsage{Path: mountPoint, Source: "mount", Available: true}
}
}
}
func fillDiskUsage(e *diskUsage) { func fillDiskUsage(e *diskUsage) {
var stat syscall.Statfs_t var stat syscall.Statfs_t
if err := syscall.Statfs(e.Path, &stat); err != nil { if err := syscall.Statfs(e.Path, &stat); err != nil {
@@ -145,3 +132,55 @@ func (s *Server) collectServiceStatus(r *http.Request) []serviceStatus {
} }
return statuses return statuses
} }
func (s *Server) handleListWatchedMounts(w http.ResponseWriter, r *http.Request) {
mounts, err := s.DB.ListWatchedMounts()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
}
func (s *Server) handleCreateWatchedMount(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := system.ValidatePathAllowed(req.Path, s.AllowedRoots); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
exists, err := s.DB.WatchedMountPathExists(req.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if exists {
writeError(w, http.StatusConflict, "path already watched")
return
}
mount, err := s.DB.CreateWatchedMount(req.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, mount)
}
func (s *Server) handleDeleteWatchedMount(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.DB.DeleteWatchedMount(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
+4
View File
@@ -32,6 +32,10 @@ func NewRouter(s *Server) chi.Router {
protected.Get("/version", s.handleVersion) protected.Get("/version", s.handleVersion)
protected.Put("/auth/password", s.handleChangePassword) protected.Put("/auth/password", s.handleChangePassword)
protected.Get("/system/watched-mounts", s.handleListWatchedMounts)
protected.Post("/system/watched-mounts", s.handleCreateWatchedMount)
protected.Delete("/system/watched-mounts/{id}", s.handleDeleteWatchedMount)
protected.Get("/import/status", s.handleImportStatus) protected.Get("/import/status", s.handleImportStatus)
protected.Post("/import/samba", s.handleImportSamba) protected.Post("/import/samba", s.handleImportSamba)
protected.Post("/import/nfs", s.handleImportNFS) protected.Post("/import/nfs", s.handleImportNFS)
+11 -1
View File
@@ -48,12 +48,17 @@ export interface DiskUsage {
free_bytes: number; free_bytes: number;
used_bytes: number; used_bytes: number;
used_percent: number; used_percent: number;
source: "system" | "mount" | "samba" | "nfs"; source: "system" | "mount" | "samba" | "nfs" | "manual";
used_by?: string[]; used_by?: string[];
available: boolean; available: boolean;
error?: string; error?: string;
} }
export interface WatchedMount {
id: number;
path: string;
}
export interface ServiceStatus { export interface ServiceStatus {
name: string; name: string;
active: boolean; active: boolean;
@@ -128,6 +133,11 @@ export const api = {
createUser: (u: Partial<User> & { password?: string }) => request<User>("POST", "/users/", u), createUser: (u: Partial<User> & { password?: string }) => request<User>("POST", "/users/", u),
updateUser: (id: number, u: Partial<User> & { password?: string }) => request<User>("PUT", `/users/${id}/`, u), updateUser: (id: number, u: Partial<User> & { password?: string }) => request<User>("PUT", `/users/${id}/`, u),
deleteUser: (id: number) => request<void>("DELETE", `/users/${id}/`), deleteUser: (id: number) => request<void>("DELETE", `/users/${id}/`),
// watched mounts
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
}; };
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
+9 -3
View File
@@ -10,6 +10,10 @@ export default function Dashboard() {
api.systemStatus().then(setStatus).catch(() => setStatus(null)); api.systemStatus().then(setStatus).catch(() => setStatus(null));
}, []); }, []);
const visibleDisks = status?.disks.filter(
(d) => d.source === "manual" || d.source === "samba" || d.source === "nfs"
) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold text-white">Dashboard</h1> <h1 className="text-2xl font-bold text-white">Dashboard</h1>
@@ -19,9 +23,9 @@ export default function Dashboard() {
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400"> <h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
Uso de disco Uso de disco
</h2> </h2>
{status?.disks?.length ? ( {visibleDisks.length ? (
<div className="space-y-4"> <div className="space-y-4">
{status.disks.map((d) => ( {visibleDisks.map((d) => (
<div key={d.path}> <div key={d.path}>
<div className="mb-1 flex justify-between text-sm"> <div className="mb-1 flex justify-between text-sm">
<span className="text-slate-300">{d.path}</span> <span className="text-slate-300">{d.path}</span>
@@ -39,7 +43,9 @@ export default function Dashboard() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-slate-500">Sin datos de disco.</p> <p className="text-sm text-slate-500">
Sin puntos de montaje vigilados. Añádelos en Ajustes.
</p>
)} )}
</div> </div>
+93 -30
View File
@@ -1,11 +1,12 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api, formatBytes, SystemStatus } from "../api"; import { api, formatBytes, SystemStatus, WatchedMount } from "../api";
const SOURCE_COLORS: Record<string, string> = { const SOURCE_COLORS: Record<string, string> = {
system: "bg-blue-500/20 text-blue-300", system: "bg-blue-500/20 text-blue-300",
mount: "bg-purple-500/20 text-purple-300", mount: "bg-purple-500/20 text-purple-300",
samba: "bg-emerald-500/20 text-emerald-300", samba: "bg-emerald-500/20 text-emerald-300",
nfs: "bg-amber-500/20 text-amber-300", nfs: "bg-amber-500/20 text-amber-300",
manual: "bg-cyan-500/20 text-cyan-300",
}; };
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
@@ -13,6 +14,7 @@ const SOURCE_LABELS: Record<string, string> = {
mount: "Mount", mount: "Mount",
samba: "SMB", samba: "SMB",
nfs: "NFS", nfs: "NFS",
manual: "Vigilado",
}; };
function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) { function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) {
@@ -57,14 +59,19 @@ function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) {
export default function Settings() { export default function Settings() {
const [oldPassword, setOldPassword] = useState(""); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<SystemStatus | null>(null); const [status, setStatus] = useState<SystemStatus | null>(null);
const [watched, setWatched] = useState<WatchedMount[]>([]);
const [newPath, setNewPath] = useState("");
const [watchError, setWatchError] = useState<string | null>(null);
const [watchLoading, setWatchLoading] = useState(false);
useEffect(() => { useEffect(() => {
api.systemStatus().then(setStatus).catch(() => setStatus(null)); api.systemStatus().then(setStatus).catch(() => setStatus(null));
api.listWatchedMounts().then(r => setWatched(r.mounts ?? [])).catch(() => setWatched([]));
}, []); }, []);
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
@@ -76,7 +83,7 @@ export default function Settings() {
setError("La nueva contraseña debe tener al menos 8 caracteres."); setError("La nueva contraseña debe tener al menos 8 caracteres.");
return; return;
} }
if (newPassword !== confirm) { if (newPassword !== confirmPassword) {
setError("La nueva contraseña y la confirmación no coinciden."); setError("La nueva contraseña y la confirmación no coinciden.");
return; return;
} }
@@ -87,7 +94,7 @@ export default function Settings() {
setSuccess(true); setSuccess(true);
setOldPassword(""); setOldPassword("");
setNewPassword(""); setNewPassword("");
setConfirm(""); setConfirmPassword("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error al cambiar la contraseña."); setError(err instanceof Error ? err.message : "Error al cambiar la contraseña.");
} finally { } finally {
@@ -95,8 +102,31 @@ export default function Settings() {
} }
} }
const systemDisks = status?.disks.filter((d) => d.source === "system" || d.source === "mount") ?? []; async function addWatched(e: FormEvent) {
const shareDisks = status?.disks.filter((d) => d.source === "samba" || d.source === "nfs") ?? []; e.preventDefault();
setWatchError(null);
const path = newPath.trim();
if (!path) return;
setWatchLoading(true);
try {
const m = await api.createWatchedMount(path);
setWatched(prev => [...prev, m].sort((a, b) => a.path.localeCompare(b.path)));
setNewPath("");
} catch (err) {
setWatchError(err instanceof Error ? err.message : "Error al añadir");
} finally {
setWatchLoading(false);
}
}
async function removeWatched(id: number) {
if (!confirm("¿Eliminar este punto de montaje vigilado?")) return;
await api.deleteWatchedMount(id);
setWatched(prev => prev.filter(m => m.id !== id));
}
const manualDisks = status?.disks.filter(d => d.source === "manual") ?? [];
const shareDisks = status?.disks.filter(d => d.source === "samba" || d.source === "nfs") ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -141,8 +171,8 @@ export default function Settings() {
<input <input
className="input" className="input"
type="password" type="password"
value={confirm} value={confirmPassword}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
@@ -154,28 +184,61 @@ export default function Settings() {
</form> </form>
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="card">
<div className="card"> <h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400"> Puntos de montaje vigilados
Puntos de montaje </h2>
</h2>
{systemDisks.length ? (
systemDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
) : (
<p className="text-sm text-slate-500">Sin datos de montaje.</p>
)}
</div>
<div className="card"> <form onSubmit={addWatched} className="mb-4 flex gap-2">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400"> <input
Recursos compartidos className="input flex-1"
</h2> placeholder="/mnt/data"
{shareDisks.length ? ( value={newPath}
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />) onChange={(e) => setNewPath(e.target.value)}
) : ( />
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p> <button className="btn-primary" type="submit" disabled={watchLoading}>
)} {watchLoading ? "..." : "Añadir"}
</div> </button>
</form>
{watchError && (
<div className="mb-3 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{watchError}</div>
)}
{watched.length > 0 ? (
<div className="space-y-2">
{watched.map((m) => (
<div key={m.id} className="flex items-center justify-between text-sm">
<span className="text-slate-300">{m.path}</span>
<button
className="btn-ghost text-xs text-slate-400 hover:text-red-300"
onClick={() => removeWatched(m.id)}
>
Quitar
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">Sin puntos de montaje vigilados.</p>
)}
{manualDisks.length > 0 && (
<div className="mt-4 border-t border-slate-800 pt-4">
{manualDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)}
</div>
)}
</div>
<div className="card">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
Recursos compartidos
</h2>
{shareDisks.length > 0 ? (
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
) : (
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p>
)}
</div> </div>
</div> </div>
); );