diff --git a/audit/internal/platform/install.go b/audit/internal/platform/install.go index 5f14c0a..598c914 100644 --- a/audit/internal/platform/install.go +++ b/audit/internal/platform/install.go @@ -3,6 +3,7 @@ package platform import ( "context" "fmt" + "os" "os/exec" "strconv" "strings" @@ -10,13 +11,17 @@ import ( // InstallDisk describes a candidate disk for installation. type InstallDisk struct { - Device string // e.g. /dev/sda - Model string - Size string // human-readable, e.g. "500G" + Device string // e.g. /dev/sda + Model string + Size string // human-readable, e.g. "500G" + SizeBytes int64 // raw byte count from lsblk + MountedParts []string // partition mount points currently active } +const squashfsPath = "/run/live/medium/live/filesystem.squashfs" + // ListInstallDisks returns block devices suitable for installation. -// Excludes USB drives and the current live boot medium. +// Excludes the current live boot medium but includes USB drives. func (s *System) ListInstallDisks() ([]InstallDisk, error) { out, err := exec.Command("lsblk", "-dn", "-o", "NAME,MODEL,SIZE,TYPE,TRAN").Output() if err != nil { @@ -33,7 +38,6 @@ func (s *System) ListInstallDisks() ([]InstallDisk, error) { continue } // Last field: TRAN, second-to-last: TYPE, third-to-last: SIZE - tran := fields[len(fields)-1] typ := fields[len(fields)-2] size := fields[len(fields)-3] name := fields[0] @@ -42,24 +46,58 @@ func (s *System) ListInstallDisks() ([]InstallDisk, error) { if typ != "disk" { continue } - if strings.EqualFold(tran, "usb") { - continue - } device := "/dev/" + name if device == bootDev { continue } + sizeBytes := diskSizeBytes(device) + mounted := mountedParts(device) + disks = append(disks, InstallDisk{ - Device: device, - Model: strings.TrimSpace(model), - Size: size, + Device: device, + Model: strings.TrimSpace(model), + Size: size, + SizeBytes: sizeBytes, + MountedParts: mounted, }) } return disks, nil } +// diskSizeBytes returns the byte size of a block device using lsblk. +func diskSizeBytes(device string) int64 { + out, err := exec.Command("lsblk", "-bdn", "-o", "SIZE", device).Output() + if err != nil { + return 0 + } + n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) + return n +} + +// mountedParts returns a list of " at " strings for any +// mounted partitions on the given device. +func mountedParts(device string) []string { + out, err := exec.Command("lsblk", "-n", "-o", "NAME,MOUNTPOINT", device).Output() + if err != nil { + return nil + } + var result []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + mp := fields[1] + if mp == "" || mp == "[SWAP]" { + continue + } + result = append(result, "/dev/"+strings.TrimLeft(fields[0], "└─├─")+" at "+mp) + } + return result +} + // findLiveBootDevice returns the block device backing /run/live/medium (if any). func findLiveBootDevice() string { out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", "/run/live/medium").Output() @@ -79,6 +117,80 @@ func findLiveBootDevice() string { return "/dev/" + strings.TrimSpace(string(out2)) } +// MinInstallBytes returns the minimum recommended disk size for installation: +// squashfs size × 1.5 to allow for extracted filesystem and bootloader. +// Returns 0 if the squashfs is not available (non-live environment). +func MinInstallBytes() int64 { + fi, err := os.Stat(squashfsPath) + if err != nil { + return 0 + } + return fi.Size() * 3 / 2 +} + +// toramActive returns true when the live system was booted with toram. +func toramActive() bool { + data, err := os.ReadFile("/proc/cmdline") + if err != nil { + return false + } + return strings.Contains(string(data), "toram") +} + +// freeMemBytes returns MemAvailable from /proc/meminfo. +func freeMemBytes() int64 { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0 + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "MemAvailable:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + n, _ := strconv.ParseInt(fields[1], 10, 64) + return n * 1024 // kB → bytes + } + } + } + return 0 +} + +// DiskWarnings returns advisory warning strings for a disk candidate. +func DiskWarnings(d InstallDisk) []string { + var w []string + if len(d.MountedParts) > 0 { + w = append(w, "has mounted partitions: "+strings.Join(d.MountedParts, ", ")) + } + min := MinInstallBytes() + if min > 0 && d.SizeBytes > 0 && d.SizeBytes < min { + w = append(w, fmt.Sprintf("disk may be too small (need ≥ %s, have %s)", + humanBytes(min), humanBytes(d.SizeBytes))) + } + if toramActive() { + sqFi, err := os.Stat(squashfsPath) + if err == nil { + free := freeMemBytes() + if free > 0 && free < sqFi.Size()*2 { + w = append(w, "toram mode — low RAM, extraction may be slow or fail") + } + } + } + return w +} + +func humanBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + // InstallToDisk runs bee-install and streams output to logFile. // The context can be used to cancel. func (s *System) InstallToDisk(ctx context.Context, device string, logFile string) error { @@ -92,14 +204,11 @@ func InstallLogPath(device string) string { return "/tmp/bee-install" + safe + ".log" } -// DiskLabel returns a display label for a disk. +// Label returns a display label for a disk. func (d InstallDisk) Label() string { model := d.Model if model == "" { model = "Unknown" } - sizeBytes, err := strconv.ParseInt(strings.TrimSuffix(d.Size, "B"), 10, 64) - _ = sizeBytes - _ = err return fmt.Sprintf("%s %s %s", d.Device, d.Size, model) } diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 54f7bab..48d1f57 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -409,6 +409,101 @@ func (h *handler) handleAPIPreflight(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// ── Install ─────────────────────────────────────────────────────────────────── + +func (h *handler) handleAPIInstallDisks(w http.ResponseWriter, r *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + disks, err := h.opts.App.ListInstallDisks() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + type diskJSON struct { + Device string `json:"device"` + Model string `json:"model"` + Size string `json:"size"` + SizeBytes int64 `json:"size_bytes"` + MountedParts []string `json:"mounted_parts"` + Warnings []string `json:"warnings"` + } + result := make([]diskJSON, 0, len(disks)) + for _, d := range disks { + result = append(result, diskJSON{ + Device: d.Device, + Model: d.Model, + Size: d.Size, + SizeBytes: d.SizeBytes, + MountedParts: d.MountedParts, + Warnings: platform.DiskWarnings(d), + }) + } + writeJSON(w, result) +} + +func (h *handler) handleAPIInstallRun(w http.ResponseWriter, r *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + var req struct { + Device string `json:"device"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Device == "" { + writeError(w, http.StatusBadRequest, "device is required") + return + } + + // Whitelist: only allow devices that ListInstallDisks() returns. + disks, err := h.opts.App.ListInstallDisks() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + allowed := false + for _, d := range disks { + if d.Device == req.Device { + allowed = true + break + } + } + if !allowed { + writeError(w, http.StatusBadRequest, "device not in install candidate list") + return + } + + h.installMu.Lock() + if h.installJob != nil && !h.installJob.isDone() { + h.installMu.Unlock() + writeError(w, http.StatusConflict, "install already running") + return + } + j := &jobState{} + h.installJob = j + h.installMu.Unlock() + + logFile := platform.InstallLogPath(req.Device) + go runCmdJob(j, exec.CommandContext(r.Context(), "bee-install", req.Device, logFile)) + + w.WriteHeader(http.StatusNoContent) +} + +func (h *handler) handleAPIInstallStream(w http.ResponseWriter, r *http.Request) { + h.installMu.Lock() + j := h.installJob + h.installMu.Unlock() + if j == nil { + if !sseStart(w) { + return + } + sseWrite(w, "done", "") + return + } + streamJob(w, r, j) +} + // ── Metrics SSE ─────────────────────────────────────────────────────────────── func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) { diff --git a/audit/internal/webui/jobs.go b/audit/internal/webui/jobs.go index dfbaf75..1992ed8 100644 --- a/audit/internal/webui/jobs.go +++ b/audit/internal/webui/jobs.go @@ -76,6 +76,13 @@ func (m *jobManager) create(id string) *jobState { return j } +// isDone returns true if the job has finished (either successfully or with error). +func (j *jobState) isDone() bool { + j.mu.Lock() + defer j.mu.Unlock() + return j.done +} + func (m *jobManager) get(id string) (*jobState, bool) { m.mu.Lock() defer m.mu.Unlock() diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index d9f3fa9..7e4043f 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -94,6 +94,7 @@ func layoutNav(active string) string { {"services", "Services", "/services"}, {"export", "Export", "/export"}, {"tools", "Tools", "/tools"}, + {"install", "Install to Disk", "/install"}, } var b strings.Builder b.WriteString(`