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:
@@ -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'))
|
||||
);
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user