337 lines
9.9 KiB
Go
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
|
|
}
|