Files
nadir-agent/internal/modules/storage/storage.go
T
2026-06-24 17:29:45 +02:00

337 lines
9.9 KiB
Go

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
}