Files
baby-nas/internal/web/handlers_system.go
T
darroyo e3add68584 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
2026-07-06 00:23:49 -04:00

187 lines
4.7 KiB
Go

package web
import (
"encoding/json"
"net/http"
"sort"
"strings"
"syscall"
"github.com/go-chi/chi/v5"
"github.com/darroyo/nasctl/internal/system"
)
type diskUsage struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedBytes uint64 `json:"used_bytes"`
UsedPercent float64 `json:"used_percent"`
Source string `json:"source"`
UsedBy []string `json:"used_by,omitempty"`
Available bool `json:"available"`
Error string `json:"error,omitempty"`
}
type serviceStatus struct {
Name string `json:"name"`
Active bool `json:"active"`
State string `json:"state"`
}
func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"disks": s.collectDiskUsage(),
"services": s.collectServiceStatus(r),
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"version": Version,
"commit": Commit,
})
}
func (s *Server) collectDiskUsage() []diskUsage {
entries := make(map[string]*diskUsage)
shares, err := s.DB.ListSambaShares()
if err == nil {
for _, share := range shares {
key := share.Path
if e, ok := entries[key]; ok {
e.UsedBy = append(e.UsedBy, share.Name)
} else {
entries[key] = &diskUsage{Path: key, Source: "samba", UsedBy: []string{share.Name}, Available: true}
}
}
}
exports, err := s.DB.ListNFSExports()
if err == nil {
for _, exp := range exports {
key := exp.Path
label := "nfs:" + exp.Path
if e, ok := entries[key]; ok {
e.UsedBy = append(e.UsedBy, label)
} else {
entries[key] = &diskUsage{Path: key, Source: "nfs", UsedBy: []string{label}, Available: true}
}
}
}
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 {
fillDiskUsage(e)
result = append(result, *e)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Source != result[j].Source {
return result[i].Source < result[j].Source
}
return result[i].Path < result[j].Path
})
return result
}
func fillDiskUsage(e *diskUsage) {
var stat syscall.Statfs_t
if err := syscall.Statfs(e.Path, &stat); err != nil {
e.Available = false
e.Error = "path no disponible"
return
}
e.Available = true
e.TotalBytes = stat.Blocks * uint64(stat.Bsize)
e.FreeBytes = stat.Bavail * uint64(stat.Bsize)
e.UsedBytes = e.TotalBytes - e.FreeBytes
if e.TotalBytes > 0 {
e.UsedPercent = float64(e.UsedBytes) / float64(e.TotalBytes) * 100
}
}
func (s *Server) collectServiceStatus(r *http.Request) []serviceStatus {
services := []string{"smbd", "nfs-server"}
var statuses []serviceStatus
for _, name := range services {
stdout, _, err := system.Run(r.Context(), "systemctl", "is-active", name)
state := strings.TrimSpace(stdout)
if state == "" && err != nil {
state = "unknown"
}
statuses = append(statuses, serviceStatus{
Name: name,
Active: state == "active",
State: state,
})
}
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)
}