package storage import ( "context" "fmt" "os" "strconv" "strings" "sync" "nadir/internal/mounts" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" ) // fstabFile is a var so tests can point it at a fixture. var fstabFile = "/etc/fstab" // fstabMu serialises writes to /etc/fstab so concurrent HTTP requests don't // clobber each other's read-modify-write. var fstabMu sync.Mutex // FstabEntry is one /etc/fstab line. Dump and Pass are the last two numeric // fields (fs_freq and fs_passno). type FstabEntry struct { Device string `json:"device" example:"UUID=1234-5678"` Mountpoint string `json:"mountpoint" example:"/mnt/data"` FSType string `json:"fstype" example:"ext4"` Options string `json:"options" example:"defaults"` Dump int `json:"dump" example:"0"` Pass int `json:"pass" example:"2" doc:"fsck order (0 = skip)"` } type ListMountsOutput struct { Body struct { Mounts []mounts.Mount `json:"mounts"` } } type ListFstabOutput struct { Body struct { Entries []FstabEntry `json:"entries"` } } type MountInput struct { Body struct { Device string `json:"device" example:"/dev/sdb1" doc:"Block device or UUID=/LABEL= specifier"` Mountpoint string `json:"mountpoint" example:"/mnt/data" doc:"Absolute mount path"` FSType string `json:"fstype" example:"ext4"` Options string `json:"options,omitempty" example:"defaults" doc:"Mount options (default: defaults)"` Dump int `json:"dump,omitempty"` Pass int `json:"pass,omitempty" doc:"fsck order (default 2 for real filesystems, 0 to skip)"` } } type UnmountInput struct { Mountpoint string `query:"mountpoint" example:"/mnt/data" doc:"Mountpoint to unmount and remove from fstab"` } func registerStorage(api huma.API) { huma.Register(api, huma.Operation{ OperationID: "storage-list-mounts", Method: "GET", Path: "/api/storage/mounts", Summary: "List active mounts", Description: "Returns the kernel mount table (/proc/mounts).", Tags: []string{tagStorage}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListMountsOutput, error) { entries, err := mounts.Proc() if err != nil { return nil, huma.Error500InternalServerError("mount table lookup failed", err) } res := &ListMountsOutput{} res.Body.Mounts = entries return res, nil }) huma.Register(api, huma.Operation{ OperationID: "storage-list-fstab", Method: "GET", Path: "/api/storage/fstab", Summary: "List /etc/fstab entries", Description: "Returns the persistent mount definitions from /etc/fstab.", Tags: []string{tagStorage}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListFstabOutput, error) { data, err := os.ReadFile(fstabFile) if err != nil { return nil, huma.Error500InternalServerError("fstab lookup failed", err) } res := &ListFstabOutput{} res.Body.Entries = parseFstab(string(data)) return res, nil }) huma.Register(api, huma.Operation{ OperationID: "storage-add-mount", Method: "POST", Path: "/api/storage/mounts", Summary: "Add and mount a filesystem", Description: "Appends an /etc/fstab entry and mounts it. If the mount fails the " + "fstab entry is rolled back, so a bad request leaves the system unchanged.", Tags: []string{tagStorage}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *MountInput) (*oscmd.StatusOutput, error) { e := FstabEntry{ Device: in.Body.Device, Mountpoint: in.Body.Mountpoint, FSType: in.Body.FSType, Options: in.Body.Options, Dump: in.Body.Dump, Pass: in.Body.Pass, } if e.Options == "" { e.Options = "defaults" } if err := validateEntry(e); err != nil { return nil, err } existing, err := readFstab() if err != nil { return nil, huma.Error500InternalServerError("fstab lookup failed", err) } if findEntry(existing, e.Mountpoint) != nil { return nil, huma.Error409Conflict("an fstab entry already exists for " + e.Mountpoint) } if err := appendFstabLine(e); err != nil { return nil, huma.Error500InternalServerError("write fstab failed", err) } // mount reads the fstab line we just wrote. On failure, roll the line back // so a bad device/options doesn't linger in fstab and break the next boot. if _, err := oscmd.RunContext(ctx, "mount", "--", e.Mountpoint); err != nil { if _, werr := removeFstabLines(e.Mountpoint); werr != nil { return nil, huma.Error500InternalServerError("mount failed and fstab rollback failed", werr) } return nil, huma.Error400BadRequest("mount failed: " + err.Error()) } return oscmd.OK(), nil }) huma.Register(api, huma.Operation{ OperationID: "storage-remove-mount", Method: "DELETE", Path: "/api/storage/mounts", Summary: "Unmount and remove a filesystem", Description: "Unmounts the filesystem (if mounted) and removes its /etc/fstab entry. " + "The mountpoint is passed as a query parameter since it contains slashes.", Tags: []string{tagStorage}, Metadata: op("write"), Errors: []int{400, 401, 403, 404, 409, 500}, }, func(ctx context.Context, in *UnmountInput) (*oscmd.StatusOutput, error) { if err := validateMountpoint(in.Mountpoint); err != nil { return nil, err } entries, err := readFstab() if err != nil { return nil, huma.Error500InternalServerError("fstab lookup failed", err) } inFstab := findEntry(entries, in.Mountpoint) != nil mounted, err := isMounted(in.Mountpoint) if err != nil { return nil, huma.Error500InternalServerError("mount table lookup failed", err) } if !inFstab && !mounted { return nil, huma.Error404NotFound("no mount or fstab entry for " + in.Mountpoint) } if mounted { if _, err := oscmd.RunContext(ctx, "umount", "--", in.Mountpoint); err != nil { return nil, huma.Error409Conflict("unmount failed (in use?): " + err.Error()) } } if inFstab { if _, err := removeFstabLines(in.Mountpoint); err != nil { return nil, huma.Error500InternalServerError("write fstab failed", err) } } return oscmd.OK(), nil }) } // --- fstab helpers ----------------------------------------------------------- func readFstab() ([]FstabEntry, error) { data, err := os.ReadFile(fstabFile) if err != nil { return nil, err } return parseFstab(string(data)), nil } // parseFstab parses /etc/fstab, skipping comments and blank lines. func parseFstab(data string) []FstabEntry { entries := []FstabEntry{} for line := range strings.SplitSeq(data, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } f := strings.Fields(trimmed) if len(f) < 4 { // device mountpoint fstype options [dump] [pass] continue } e := FstabEntry{ Device: mounts.Unescape(f[0]), Mountpoint: mounts.Unescape(f[1]), FSType: f[2], Options: f[3], } if len(f) >= 5 { e.Dump, _ = strconv.Atoi(f[4]) } if len(f) >= 6 { e.Pass, _ = strconv.Atoi(f[5]) } entries = append(entries, e) } return entries } // fstab edits are surgical (append one line / drop matching lines) rather than a // whole-file rewrite, so an admin's existing entries, comments and formatting in // this boot-critical file are preserved — same approach as /etc/hosts. func renderFstabLine(e FstabEntry) string { return fmt.Sprintf("%s\t%s\t%s\t%s\t%d\t%d", e.Device, e.Mountpoint, e.FSType, e.Options, e.Dump, e.Pass) } // writeFstabAtomically writes content to /etc/fstab using a temporary file and // rename, so a crash mid-write leaves the original file intact. func writeFstabAtomically(content string) error { tmp := fstabFile + ".nadir.tmp" if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { return err } if err := os.Rename(tmp, fstabFile); err != nil { os.Remove(tmp) return err } return nil } // appendFstabLine adds one entry, leaving every existing line untouched. func appendFstabLine(e FstabEntry) error { fstabMu.Lock() defer fstabMu.Unlock() data, err := os.ReadFile(fstabFile) if err != nil { return err } content := string(data) if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } content += renderFstabLine(e) + "\n" return writeFstabAtomically(content) } // removeFstabLines drops every line mapping mountpoint, preserving comments and // other entries. Reports whether anything was removed. func removeFstabLines(mountpoint string) (bool, error) { fstabMu.Lock() defer fstabMu.Unlock() data, err := os.ReadFile(fstabFile) if err != nil { return false, err } lines := strings.Split(string(data), "\n") kept := make([]string, 0, len(lines)) removed := false for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" && !strings.HasPrefix(trimmed, "#") { if f := strings.Fields(trimmed); len(f) >= 2 && mounts.Unescape(f[1]) == mountpoint { removed = true continue } } kept = append(kept, line) } if !removed { return false, nil } return true, writeFstabAtomically(strings.Join(kept, "\n")) } func findEntry(entries []FstabEntry, mountpoint string) *FstabEntry { for i := range entries { if entries[i].Mountpoint == mountpoint { return &entries[i] } } return nil } // isMounted reports whether mountpoint currently appears in /proc/mounts. func isMounted(mountpoint string) (bool, error) { active, err := mounts.Proc() if err != nil { return false, err } for _, e := range active { if e.Mountpoint == mountpoint { return true, nil } } return false, nil } func validateEntry(e FstabEntry) error { if !deviceRe.MatchString(e.Device) { return huma.Error400BadRequest("invalid device: " + e.Device) } if err := validateMountpoint(e.Mountpoint); err != nil { return err } if !fstypeRe.MatchString(e.FSType) { return huma.Error400BadRequest("invalid fstype: " + e.FSType) } if !optionsRe.MatchString(e.Options) { return huma.Error400BadRequest("invalid options: " + e.Options) } return nil }