From e3add6858440087bb09a6b6cf6ceed18c8cf9291 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Mon, 6 Jul 2026 00:23:49 -0400 Subject: [PATCH] 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 --- Makefile | 2 +- .../db/migrations/0003_watched_mounts.sql | 5 + internal/db/models.go | 6 + internal/db/queries_watched.go | 87 +++++++++++++ internal/web/handlers_system.go | 89 +++++++++---- internal/web/router.go | 4 + web/src/api.ts | 12 +- web/src/pages/Dashboard.tsx | 12 +- web/src/pages/Settings.tsx | 123 +++++++++++++----- 9 files changed, 280 insertions(+), 60 deletions(-) create mode 100644 internal/db/migrations/0003_watched_mounts.sql create mode 100644 internal/db/queries_watched.go diff --git a/Makefile b/Makefile index 4e818f4..78220a4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.1.10 +VERSION?=0.2.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/migrations/0003_watched_mounts.sql b/internal/db/migrations/0003_watched_mounts.sql new file mode 100644 index 0000000..2b32d6a --- /dev/null +++ b/internal/db/migrations/0003_watched_mounts.sql @@ -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')) +); diff --git a/internal/db/models.go b/internal/db/models.go index 634e9be..fc31c12 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -59,6 +59,12 @@ type NFSAdvanced struct { Crossmnt bool `json:"crossmnt"` } +type WatchedMount struct { + ID int64 `json:"id"` + Path string `json:"path"` + CreatedAt time.Time `json:"created_at"` +} + type DirtyModule struct { Module string `json:"module"` MarkedAt time.Time `json:"marked_at"` diff --git a/internal/db/queries_watched.go b/internal/db/queries_watched.go new file mode 100644 index 0000000..bf635a4 --- /dev/null +++ b/internal/db/queries_watched.go @@ -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 +} diff --git a/internal/web/handlers_system.go b/internal/web/handlers_system.go index b70a541..ecfc67c 100644 --- a/internal/web/handlers_system.go +++ b/internal/web/handlers_system.go @@ -1,13 +1,14 @@ package web import ( - "bufio" + "encoding/json" "net/http" - "os" "sort" "strings" "syscall" + "github.com/go-chi/chi/v5" + "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 { entries := make(map[string]*diskUsage) - entries["/"] = &diskUsage{Path: "/", Source: "system", Available: true} - shares, err := s.DB.ListSambaShares() if err == nil { 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 for _, e := range entries { @@ -92,26 +99,6 @@ func (s *Server) collectDiskUsage() []diskUsage { 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) { var stat syscall.Statfs_t if err := syscall.Statfs(e.Path, &stat); err != nil { @@ -145,3 +132,55 @@ func (s *Server) collectServiceStatus(r *http.Request) []serviceStatus { } 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) +} diff --git a/internal/web/router.go b/internal/web/router.go index 5431bd3..6118ef4 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -32,6 +32,10 @@ func NewRouter(s *Server) chi.Router { protected.Get("/version", s.handleVersion) 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.Post("/import/samba", s.handleImportSamba) protected.Post("/import/nfs", s.handleImportNFS) diff --git a/web/src/api.ts b/web/src/api.ts index bb79063..527ff93 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -48,12 +48,17 @@ export interface DiskUsage { free_bytes: number; used_bytes: number; used_percent: number; - source: "system" | "mount" | "samba" | "nfs"; + source: "system" | "mount" | "samba" | "nfs" | "manual"; used_by?: string[]; available: boolean; error?: string; } +export interface WatchedMount { + id: number; + path: string; +} + export interface ServiceStatus { name: string; active: boolean; @@ -128,6 +133,11 @@ export const api = { createUser: (u: Partial & { password?: string }) => request("POST", "/users/", u), updateUser: (id: number, u: Partial & { password?: string }) => request("PUT", `/users/${id}/`, u), deleteUser: (id: number) => request("DELETE", `/users/${id}/`), + + // watched mounts + listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"), + createWatchedMount: (path: string) => request("POST", "/system/watched-mounts", { path }), + deleteWatchedMount: (id: number) => request("DELETE", `/system/watched-mounts/${id}`), }; export function formatBytes(bytes: number): string { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 1c984db..ee5d022 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -10,6 +10,10 @@ export default function Dashboard() { api.systemStatus().then(setStatus).catch(() => setStatus(null)); }, []); + const visibleDisks = status?.disks.filter( + (d) => d.source === "manual" || d.source === "samba" || d.source === "nfs" + ) ?? []; + return (

Dashboard

@@ -19,9 +23,9 @@ export default function Dashboard() {

Uso de disco

- {status?.disks?.length ? ( + {visibleDisks.length ? (
- {status.disks.map((d) => ( + {visibleDisks.map((d) => (
{d.path} @@ -39,7 +43,9 @@ export default function Dashboard() { ))}
) : ( -

Sin datos de disco.

+

+ Sin puntos de montaje vigilados. Añádelos en Ajustes. +

)}
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index de68a9d..6cd3876 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,11 +1,12 @@ import { FormEvent, useEffect, useState } from "react"; -import { api, formatBytes, SystemStatus } from "../api"; +import { api, formatBytes, SystemStatus, WatchedMount } from "../api"; const SOURCE_COLORS: Record = { system: "bg-blue-500/20 text-blue-300", mount: "bg-purple-500/20 text-purple-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 = { @@ -13,6 +14,7 @@ const SOURCE_LABELS: Record = { mount: "Mount", samba: "SMB", nfs: "NFS", + manual: "Vigilado", }; function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) { @@ -57,14 +59,19 @@ function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) { export default function Settings() { const [oldPassword, setOldPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); - const [confirm, setConfirm] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(null); + const [watched, setWatched] = useState([]); + const [newPath, setNewPath] = useState(""); + const [watchError, setWatchError] = useState(null); + const [watchLoading, setWatchLoading] = useState(false); useEffect(() => { api.systemStatus().then(setStatus).catch(() => setStatus(null)); + api.listWatchedMounts().then(r => setWatched(r.mounts ?? [])).catch(() => setWatched([])); }, []); async function handleSubmit(e: FormEvent) { @@ -76,7 +83,7 @@ export default function Settings() { setError("La nueva contraseña debe tener al menos 8 caracteres."); return; } - if (newPassword !== confirm) { + if (newPassword !== confirmPassword) { setError("La nueva contraseña y la confirmación no coinciden."); return; } @@ -87,7 +94,7 @@ export default function Settings() { setSuccess(true); setOldPassword(""); setNewPassword(""); - setConfirm(""); + setConfirmPassword(""); } catch (err) { setError(err instanceof Error ? err.message : "Error al cambiar la contraseña."); } finally { @@ -95,8 +102,31 @@ export default function Settings() { } } - const systemDisks = status?.disks.filter((d) => d.source === "system" || d.source === "mount") ?? []; - const shareDisks = status?.disks.filter((d) => d.source === "samba" || d.source === "nfs") ?? []; + async function addWatched(e: FormEvent) { + 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 (
@@ -141,8 +171,8 @@ export default function Settings() { setConfirm(e.target.value)} + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} autoComplete="new-password" />
@@ -154,28 +184,61 @@ export default function Settings() {
-
-
-

- Puntos de montaje -

- {systemDisks.length ? ( - systemDisks.map((d) => ) - ) : ( -

Sin datos de montaje.

- )} -
+
+

+ Puntos de montaje vigilados +

-
-

- Recursos compartidos -

- {shareDisks.length ? ( - shareDisks.map((d) => ) - ) : ( -

Sin shares ni exports configurados.

- )} -
+
+ setNewPath(e.target.value)} + /> + +
+ + {watchError && ( +
{watchError}
+ )} + + {watched.length > 0 ? ( +
+ {watched.map((m) => ( +
+ {m.path} + +
+ ))} +
+ ) : ( +

Sin puntos de montaje vigilados.

+ )} + + {manualDisks.length > 0 && ( +
+ {manualDisks.map((d) => )} +
+ )} +
+ +
+

+ Recursos compartidos +

+ {shareDisks.length > 0 ? ( + shareDisks.map((d) => ) + ) : ( +

Sin shares ni exports configurados.

+ )}
);