banger/internal/webui/server.go
Thales Maciel 2362d0ae39
Serve a local web UI from bangerd
Add a localhost-only web console so VM and image management no longer depends on the CLI for every inspection and lifecycle action.

Wire bangerd up to a configurable web listener, expose dashboard and async image-build state through the daemon, and serve CSRF-protected HTML pages with host-path picking, VM/image detail views, logs, ports, and progress polling for long-running operations.

Keep the browser path aligned with the existing sudo and host-owned artifact model: surface sudo readiness, print the web URL in daemon status, and document the new workflow. Polish the UI with resource usage cards, clearer clickable affordances, cancel paths, confirmation prompts, image-name links, and HTTP port links.

Validation: GOCACHE=/tmp/banger-gocache go test ./...
2026-03-21 16:47:47 -03:00

1246 lines
36 KiB
Go

package webui
import (
"context"
"crypto/rand"
"embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"banger/internal/api"
"banger/internal/model"
"banger/internal/paths"
)
type Backend interface {
Config() model.DaemonConfig
Layout() paths.Layout
DashboardSummary(context.Context) (api.DashboardSummary, error)
ListVMs(context.Context) ([]model.VMRecord, error)
FindVM(context.Context, string) (model.VMRecord, error)
GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error)
BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error)
VMCreateStatus(context.Context, string) (api.VMCreateOperation, error)
StartVM(context.Context, string) (model.VMRecord, error)
StopVM(context.Context, string) (model.VMRecord, error)
RestartVM(context.Context, string) (model.VMRecord, error)
DeleteVM(context.Context, string) (model.VMRecord, error)
SetVM(context.Context, api.VMSetParams) (model.VMRecord, error)
PortsVM(context.Context, string) (api.VMPortsResult, error)
ListImages(context.Context) ([]model.Image, error)
FindImage(context.Context, string) (model.Image, error)
BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error)
ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error)
RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error)
PromoteImage(context.Context, string) (model.Image, error)
DeleteImage(context.Context, string) (model.Image, error)
}
type Server struct {
backend Backend
templates *template.Template
pickerFS fs.FS
}
type pickerRoot struct {
Label string
Path string
}
type flashMessage struct {
Kind string
Message string
}
type vmCreateForm struct {
Name string
ImageName string
VCPU string
Memory string
SystemOverlaySize string
WorkDiskSize string
NATEnabled bool
NoStart bool
}
type vmSetForm struct {
VCPU string
Memory string
WorkDiskSize string
NATEnabled bool
}
type imageBuildForm struct {
Name string
BaseRootfs string
Size string
KernelPath string
InitrdPath string
ModulesDir string
Docker bool
}
type imageRegisterForm struct {
Name string
RootfsPath string
WorkSeedPath string
KernelPath string
InitrdPath string
ModulesDir string
PackagesPath string
Docker bool
}
type pageData struct {
Title string
BodyTemplate string
BodyHTML template.HTML
Section string
Summary api.DashboardSummary
Flash *flashMessage
CSRFToken string
PickerRoots []pickerRoot
MutationAllowed bool
ErrorMessage string
VMs []model.VMRecord
VM model.VMRecord
VMImage model.Image
VMStats model.VMStats
VMPorts api.VMPortsResult
VMPortsError string
VMCreateForm vmCreateForm
VMSetForm vmSetForm
Images []model.Image
Image model.Image
ImageUsers int
ImageBuildForm imageBuildForm
ImageRegisterForm imageRegisterForm
LogText string
VMCreateOperation *api.VMCreateOperation
ImageBuildOperation *api.ImageBuildOperation
OperationStatusURL string
OperationSuccessURL string
OperationLogPath string
OperationKind string
}
type fsEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind string `json:"kind"`
}
type fsListingResponse struct {
Path string `json:"path"`
Parent string `json:"parent,omitempty"`
Kind string `json:"kind"`
Entries []fsEntry `json:"entries"`
Roots []pickerRoot `json:"roots"`
}
//go:embed templates/*.html assets/*
var embeddedAssets embed.FS
func NewHandler(backend Backend) http.Handler {
tmpl := template.Must(template.New("page").Funcs(template.FuncMap{
"shortID": shortID,
"formatBytes": formatBytes,
"formatBytesCompact": formatBytesCompact,
"formatPercent": formatPercent,
"percentOf": percentOf,
"relativeTime": relativeTime,
"formatBool": formatBool,
"stateClass": stateClass,
"findImage": findImage,
"endpointHref": endpointHref,
"sumInt64": sumInt64,
"eq": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
}).ParseFS(embeddedAssets, "templates/*.html"))
staticFS, err := fs.Sub(embeddedAssets, "assets")
if err != nil {
panic(err)
}
server := &Server{
backend: backend,
templates: tmpl,
pickerFS: staticFS,
}
mux := http.NewServeMux()
server.registerRoutes(mux)
return mux
}
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.pickerFS)))
mux.HandleFunc("GET /", s.wrap(s.handleDashboard))
mux.HandleFunc("GET /vms", s.wrap(s.handleVMList))
mux.HandleFunc("GET /vms/new", s.wrap(s.handleVMNew))
mux.HandleFunc("POST /vms", s.wrap(s.handleVMCreate))
mux.HandleFunc("GET /vms/{id}", s.wrap(s.handleVMShow))
mux.HandleFunc("GET /vms/{id}/logs", s.wrap(s.handleVMLogs))
mux.HandleFunc("POST /vms/{id}/start", s.wrap(s.handleVMStart))
mux.HandleFunc("POST /vms/{id}/stop", s.wrap(s.handleVMStop))
mux.HandleFunc("POST /vms/{id}/restart", s.wrap(s.handleVMRestart))
mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete))
mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet))
mux.HandleFunc("GET /images", s.wrap(s.handleImageList))
mux.HandleFunc("GET /images/build", s.wrap(s.handleImageBuildForm))
mux.HandleFunc("POST /images/build", s.wrap(s.handleImageBuild))
mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm))
mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister))
mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow))
mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote))
mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete))
mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage))
mux.HandleFunc("GET /operations/image-build/{id}", s.wrap(s.handleImageBuildOperationPage))
mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI))
mux.HandleFunc("GET /api/operations/image-build/{id}", s.wrap(s.handleImageBuildOperationAPI))
mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI))
}
func (s *Server) wrap(fn func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
s.writeError(w, r, err)
}
}
}
func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusInternalServerError
lower := strings.ToLower(err.Error())
switch {
case errors.Is(err, os.ErrNotExist), strings.Contains(lower, "not found"):
status = http.StatusNotFound
case strings.Contains(lower, "csrf"), strings.Contains(lower, "cross-origin"):
status = http.StatusForbidden
case strings.Contains(lower, "path must"), strings.Contains(lower, "not a directory"):
status = http.StatusBadRequest
}
if status == http.StatusInternalServerError {
http.Error(w, err.Error(), status)
return
}
if renderErr := s.renderPage(w, r, status, "Not Found", "error_content", func(data *pageData) error {
data.Section = "none"
data.ErrorMessage = err.Error()
return nil
}); renderErr != nil {
http.Error(w, err.Error(), status)
}
}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, title, body string, fill func(*pageData) error) error {
summary, err := s.backend.DashboardSummary(r.Context())
if err != nil {
return err
}
flash := s.popFlash(w, r)
data := &pageData{
Title: title,
BodyTemplate: body,
Summary: summary,
Flash: flash,
CSRFToken: s.ensureCSRFToken(w, r),
PickerRoots: s.pickerRoots(),
MutationAllowed: summary.Sudo.Available,
}
if fill != nil {
if err := fill(data); err != nil {
return err
}
}
var bodyHTML strings.Builder
if err := s.templates.ExecuteTemplate(&bodyHTML, body, data); err != nil {
return err
}
data.BodyHTML = template.HTML(bodyHTML.String())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
return s.templates.ExecuteTemplate(w, "page", data)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error {
return s.renderPage(w, r, http.StatusOK, "Dashboard", "dashboard_content", func(data *pageData) error {
data.Section = "dashboard"
vms, err := s.backend.ListVMs(r.Context())
if err != nil {
return err
}
images, err := s.backend.ListImages(r.Context())
if err != nil {
return err
}
data.VMs = vms
data.Images = images
return nil
})
}
func (s *Server) handleVMList(w http.ResponseWriter, r *http.Request) error {
return s.renderPage(w, r, http.StatusOK, "VMs", "vm_list_content", func(data *pageData) error {
data.Section = "vms"
vms, err := s.backend.ListVMs(r.Context())
if err != nil {
return err
}
images, err := s.backend.ListImages(r.Context())
if err != nil {
return err
}
data.VMs = vms
data.Images = images
return nil
})
}
func (s *Server) handleVMNew(w http.ResponseWriter, r *http.Request) error {
return s.renderVMNewPage(w, r, vmCreateForm{
VCPU: strconv.Itoa(model.DefaultVCPUCount),
Memory: strconv.Itoa(model.DefaultMemoryMiB),
SystemOverlaySize: model.FormatSizeBytes(model.DefaultSystemOverlaySize),
WorkDiskSize: model.FormatSizeBytes(model.DefaultWorkDiskSize),
}, "")
}
func (s *Server) renderVMNewPage(w http.ResponseWriter, r *http.Request, form vmCreateForm, formErr string) error {
return s.renderPage(w, r, http.StatusOK, "Create VM", "vm_new_content", func(data *pageData) error {
data.Section = "vms"
images, err := s.backend.ListImages(r.Context())
if err != nil {
return err
}
data.Images = images
data.VMCreateForm = form
data.ErrorMessage = formErr
return nil
})
}
func (s *Server) handleVMCreate(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
form, params, err := s.parseVMCreateForm(r)
if err != nil {
return s.renderVMNewPage(w, r, form, err.Error())
}
if !allowed {
return s.renderVMNewPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
}
op, err := s.backend.BeginVMCreate(r.Context(), params)
if err != nil {
return s.renderVMNewPage(w, r, form, err.Error())
}
http.Redirect(w, r, "/operations/vm-create/"+url.PathEscape(op.ID), http.StatusSeeOther)
return nil
}
func (s *Server) handleVMShow(w http.ResponseWriter, r *http.Request) error {
_, vmStats, err := s.backend.GetVMStats(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
image, _ := s.backend.FindImage(r.Context(), vm.ImageID)
return s.renderPage(w, r, http.StatusOK, vm.Name, "vm_show_content", func(data *pageData) error {
data.Section = "vms"
data.VM = vm
data.VMImage = image
data.VMStats = vmStats
data.VMSetForm = vmSetForm{
VCPU: strconv.Itoa(vm.Spec.VCPUCount),
Memory: strconv.Itoa(vm.Spec.MemoryMiB),
WorkDiskSize: model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
NATEnabled: vm.Spec.NATEnabled,
}
if vm.State == model.VMStateRunning {
ports, err := s.backend.PortsVM(r.Context(), vm.ID)
if err != nil {
data.VMPortsError = err.Error()
} else {
data.VMPorts = ports
}
}
return nil
})
}
func (s *Server) handleVMLogs(w http.ResponseWriter, r *http.Request) error {
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
logText, err := tailFile(vm.Runtime.LogPath, 200)
if err != nil {
logText = err.Error()
}
return s.renderPage(w, r, http.StatusOK, vm.Name+" Logs", "vm_logs_content", func(data *pageData) error {
data.Section = "vms"
data.VM = vm
data.LogText = logText
return nil
})
}
func (s *Server) handleVMStart(w http.ResponseWriter, r *http.Request) error {
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
_, err := s.backend.StartVM(ctx, id)
return err
}, "VM started")
}
func (s *Server) handleVMStop(w http.ResponseWriter, r *http.Request) error {
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
_, err := s.backend.StopVM(ctx, id)
return err
}, "VM stopped")
}
func (s *Server) handleVMRestart(w http.ResponseWriter, r *http.Request) error {
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
_, err := s.backend.RestartVM(ctx, id)
return err
}, "VM restarted")
}
func (s *Server) handleVMDelete(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
if !allowed {
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther)
return nil
}
if _, err := s.backend.DeleteVM(r.Context(), r.PathValue("id")); err != nil {
s.setFlash(w, "error", err.Error())
http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther)
return nil
}
s.setFlash(w, "success", "VM deleted")
http.Redirect(w, r, "/vms", http.StatusSeeOther)
return nil
}
func (s *Server) handleVMSet(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
target := "/vms/" + url.PathEscape(r.PathValue("id"))
if !allowed {
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
params, err := s.parseVMSetForm(r, vm)
if err != nil {
s.setFlash(w, "error", err.Error())
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
s.setFlash(w, "info", "No VM settings changed")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
if _, err := s.backend.SetVM(r.Context(), params); err != nil {
s.setFlash(w, "error", err.Error())
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
s.setFlash(w, "success", "VM settings updated")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
func (s *Server) runVMAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) error, successMessage string) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
target := "/vms/" + url.PathEscape(r.PathValue("id"))
if !allowed {
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
if err := action(r.Context(), r.PathValue("id")); err != nil {
s.setFlash(w, "error", err.Error())
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
s.setFlash(w, "success", successMessage)
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error {
return s.renderPage(w, r, http.StatusOK, "Images", "image_list_content", func(data *pageData) error {
data.Section = "images"
images, err := s.backend.ListImages(r.Context())
if err != nil {
return err
}
data.Images = images
return nil
})
}
func (s *Server) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error {
cfg := s.backend.Config()
return s.renderImageBuildPage(w, r, imageBuildForm{
BaseRootfs: cfg.DefaultBaseRootfs,
KernelPath: cfg.DefaultKernel,
InitrdPath: cfg.DefaultInitrd,
ModulesDir: cfg.DefaultModulesDir,
}, "")
}
func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error {
return s.renderPage(w, r, http.StatusOK, "Build Image", "image_build_content", func(data *pageData) error {
data.Section = "images"
data.ImageBuildForm = form
data.ErrorMessage = formErr
return nil
})
}
func (s *Server) handleImageBuild(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
form, params, err := s.parseImageBuildForm(r)
if err != nil {
return s.renderImageBuildPage(w, r, form, err.Error())
}
if !allowed {
return s.renderImageBuildPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
}
op, err := s.backend.BeginImageBuild(r.Context(), params)
if err != nil {
return s.renderImageBuildPage(w, r, form, err.Error())
}
http.Redirect(w, r, "/operations/image-build/"+url.PathEscape(op.ID), http.StatusSeeOther)
return nil
}
func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error {
cfg := s.backend.Config()
return s.renderImageRegisterPage(w, r, imageRegisterForm{
KernelPath: cfg.DefaultKernel,
InitrdPath: cfg.DefaultInitrd,
ModulesDir: cfg.DefaultModulesDir,
}, "")
}
func (s *Server) renderImageRegisterPage(w http.ResponseWriter, r *http.Request, form imageRegisterForm, formErr string) error {
return s.renderPage(w, r, http.StatusOK, "Register Image", "image_register_content", func(data *pageData) error {
data.Section = "images"
data.ImageRegisterForm = form
data.ErrorMessage = formErr
return nil
})
}
func (s *Server) handleImageRegister(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
form, params, err := s.parseImageRegisterForm(r)
if err != nil {
return s.renderImageRegisterPage(w, r, form, err.Error())
}
if !allowed {
return s.renderImageRegisterPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
}
image, err := s.backend.RegisterImage(r.Context(), params)
if err != nil {
return s.renderImageRegisterPage(w, r, form, err.Error())
}
s.setFlash(w, "success", "Image registered")
http.Redirect(w, r, "/images/"+url.PathEscape(image.ID), http.StatusSeeOther)
return nil
}
func (s *Server) handleImageShow(w http.ResponseWriter, r *http.Request) error {
image, err := s.backend.FindImage(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
vms, err := s.backend.ListVMs(r.Context())
if err != nil {
return err
}
userCount := 0
for _, vm := range vms {
if vm.ImageID == image.ID {
userCount++
}
}
return s.renderPage(w, r, http.StatusOK, image.Name, "image_show_content", func(data *pageData) error {
data.Section = "images"
data.Image = image
data.ImageUsers = userCount
return nil
})
}
func (s *Server) handleImagePromote(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
target := "/images/" + url.PathEscape(r.PathValue("id"))
if !allowed {
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
if _, err := s.backend.PromoteImage(r.Context(), r.PathValue("id")); err != nil {
s.setFlash(w, "error", err.Error())
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
s.setFlash(w, "success", "Image promoted")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) error {
if err := s.verifyPOST(w, r); err != nil {
return err
}
allowed, err := s.requireMutationAllowed(r.Context())
if err != nil {
return err
}
target := "/images/" + url.PathEscape(r.PathValue("id"))
if !allowed {
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
if _, err := s.backend.DeleteImage(r.Context(), r.PathValue("id")); err != nil {
s.setFlash(w, "error", err.Error())
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
s.setFlash(w, "success", "Image deleted")
http.Redirect(w, r, "/images", http.StatusSeeOther)
return nil
}
func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Request) error {
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
return s.renderPage(w, r, http.StatusOK, "Creating VM", "operation_content", func(data *pageData) error {
data.Section = "vms"
data.OperationKind = "vm"
data.VMCreateOperation = &op
data.OperationStatusURL = "/api/operations/vm-create/" + url.PathEscape(op.ID)
if op.VMID != "" {
data.OperationSuccessURL = "/vms/" + url.PathEscape(op.VMID)
}
return nil
})
}
func (s *Server) handleImageBuildOperationPage(w http.ResponseWriter, r *http.Request) error {
op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
return s.renderPage(w, r, http.StatusOK, "Building Image", "operation_content", func(data *pageData) error {
data.Section = "images"
data.OperationKind = "image"
data.ImageBuildOperation = &op
data.OperationStatusURL = "/api/operations/image-build/" + url.PathEscape(op.ID)
if op.ImageID != "" {
data.OperationSuccessURL = "/images/" + url.PathEscape(op.ImageID)
}
data.OperationLogPath = op.BuildLogPath
return nil
})
}
func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error {
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
return writeJSON(w, api.VMCreateStatusResult{Operation: op})
}
func (s *Server) handleImageBuildOperationAPI(w http.ResponseWriter, r *http.Request) error {
op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
return writeJSON(w, api.ImageBuildStatusResult{Operation: op})
}
func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error {
path := strings.TrimSpace(r.URL.Query().Get("path"))
if path == "" {
path = s.pickerRoots()[0].Path
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return fmt.Errorf("path must be absolute")
}
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", path)
}
kind := r.URL.Query().Get("kind")
if kind != "dir" {
kind = "file"
}
entries, err := os.ReadDir(path)
if err != nil {
return err
}
result := fsListingResponse{
Path: path,
Kind: kind,
Entries: make([]fsEntry, 0, len(entries)+1),
Roots: s.pickerRoots(),
}
parent := filepath.Dir(path)
if parent != path {
result.Parent = parent
result.Entries = append(result.Entries, fsEntry{Name: "..", Path: parent, Kind: "up"})
}
for _, entry := range entries {
entryKind := "file"
if entry.IsDir() {
entryKind = "dir"
}
result.Entries = append(result.Entries, fsEntry{
Name: entry.Name(),
Path: filepath.Join(path, entry.Name()),
Kind: entryKind,
})
}
sort.Slice(result.Entries, func(i, j int) bool {
left, right := result.Entries[i], result.Entries[j]
leftRank := kindRank(left.Kind)
rightRank := kindRank(right.Kind)
if leftRank != rightRank {
return leftRank < rightRank
}
return strings.ToLower(left.Name) < strings.ToLower(right.Name)
})
return writeJSON(w, result)
}
func kindRank(kind string) int {
switch kind {
case "up":
return 0
case "dir":
return 1
default:
return 2
}
}
func (s *Server) pickerRoots() []pickerRoot {
seen := map[string]struct{}{}
roots := []pickerRoot{{Label: "Filesystem", Path: "/"}}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
roots = append(roots, pickerRoot{Label: "Home", Path: home})
}
layout := s.backend.Layout()
if layout.StateDir != "" {
roots = append(roots, pickerRoot{Label: "State", Path: layout.StateDir})
}
if runtimeDir := s.backend.Config().RuntimeDir; runtimeDir != "" {
roots = append(roots, pickerRoot{Label: "Runtime", Path: runtimeDir})
}
result := make([]pickerRoot, 0, len(roots))
for _, root := range roots {
root.Path = filepath.Clean(root.Path)
if _, ok := seen[root.Path]; ok {
continue
}
seen[root.Path] = struct{}{}
result = append(result, root)
}
return result
}
func (s *Server) verifyPOST(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost {
return nil
}
if err := r.ParseForm(); err != nil {
return err
}
if err := verifySameOrigin(r); err != nil {
return err
}
tokenCookie, err := r.Cookie("banger_csrf")
if err != nil {
return errors.New("missing csrf cookie")
}
if tokenCookie.Value == "" || r.FormValue("csrf_token") != tokenCookie.Value {
return errors.New("csrf token mismatch")
}
return nil
}
func verifySameOrigin(r *http.Request) error {
for _, raw := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
if strings.TrimSpace(raw) == "" {
continue
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid origin: %w", err)
}
if parsed.Host != r.Host {
return errors.New("cross-origin POST rejected")
}
return nil
}
return nil
}
func (s *Server) ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie("banger_csrf"); err == nil && strings.TrimSpace(cookie.Value) != "" {
return cookie.Value
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
token := hex.EncodeToString(buf)
http.SetCookie(w, &http.Cookie{
Name: "banger_csrf",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return token
}
func (s *Server) setFlash(w http.ResponseWriter, kind, message string) {
payload := base64.RawURLEncoding.EncodeToString([]byte(kind + "\n" + message))
http.SetCookie(w, &http.Cookie{
Name: "banger_flash",
Value: payload,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) *flashMessage {
cookie, err := r.Cookie("banger_flash")
if err != nil || cookie.Value == "" {
return nil
}
http.SetCookie(w, &http.Cookie{
Name: "banger_flash",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
data, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
return nil
}
parts := strings.SplitN(string(data), "\n", 2)
if len(parts) != 2 {
return nil
}
return &flashMessage{Kind: parts[0], Message: parts[1]}
}
func (s *Server) requireMutationAllowed(ctx context.Context) (bool, error) {
summary, err := s.backend.DashboardSummary(ctx)
if err != nil {
return false, err
}
return summary.Sudo.Available, nil
}
func (s *Server) parseVMCreateForm(r *http.Request) (vmCreateForm, api.VMCreateParams, error) {
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
return vmCreateForm{}, api.VMCreateParams{}, err
}
form := vmCreateForm{
Name: strings.TrimSpace(r.FormValue("name")),
ImageName: strings.TrimSpace(r.FormValue("image_name")),
VCPU: strings.TrimSpace(r.FormValue("vcpu")),
Memory: strings.TrimSpace(r.FormValue("memory")),
SystemOverlaySize: strings.TrimSpace(r.FormValue("system_overlay_size")),
WorkDiskSize: strings.TrimSpace(r.FormValue("work_disk_size")),
NATEnabled: r.FormValue("nat_enabled") == "on",
NoStart: r.FormValue("no_start") == "on",
}
vcpu, err := strconv.Atoi(form.VCPU)
if err != nil {
return form, api.VMCreateParams{}, errors.New("vcpu must be an integer")
}
memory, err := strconv.Atoi(form.Memory)
if err != nil {
return form, api.VMCreateParams{}, errors.New("memory must be an integer")
}
params := api.VMCreateParams{
Name: form.Name,
ImageName: form.ImageName,
VCPUCount: &vcpu,
MemoryMiB: &memory,
SystemOverlaySize: form.SystemOverlaySize,
WorkDiskSize: form.WorkDiskSize,
NATEnabled: form.NATEnabled,
NoStart: form.NoStart,
}
return form, params, nil
}
func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetParams, error) {
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
return api.VMSetParams{}, err
}
params := api.VMSetParams{IDOrName: vm.ID}
if raw := strings.TrimSpace(r.FormValue("vcpu")); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil {
return api.VMSetParams{}, errors.New("vcpu must be an integer")
}
if value != vm.Spec.VCPUCount {
params.VCPUCount = &value
}
}
if raw := strings.TrimSpace(r.FormValue("memory")); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil {
return api.VMSetParams{}, errors.New("memory must be an integer")
}
if value != vm.Spec.MemoryMiB {
params.MemoryMiB = &value
}
}
if raw := strings.TrimSpace(r.FormValue("work_disk_size")); raw != "" && raw != model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes) {
params.WorkDiskSize = raw
}
if raw := strings.TrimSpace(r.FormValue("nat_enabled")); raw != "" {
value := raw == "true"
if value != vm.Spec.NATEnabled {
params.NATEnabled = &value
}
}
return params, nil
}
func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.ImageBuildParams, error) {
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
return imageBuildForm{}, api.ImageBuildParams{}, err
}
form := imageBuildForm{
Name: strings.TrimSpace(r.FormValue("name")),
BaseRootfs: strings.TrimSpace(r.FormValue("base_rootfs")),
Size: strings.TrimSpace(r.FormValue("size")),
KernelPath: strings.TrimSpace(r.FormValue("kernel_path")),
InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")),
ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")),
Docker: r.FormValue("docker") == "on",
}
params := api.ImageBuildParams{
Name: form.Name,
BaseRootfs: form.BaseRootfs,
Size: form.Size,
KernelPath: form.KernelPath,
InitrdPath: form.InitrdPath,
ModulesDir: form.ModulesDir,
Docker: form.Docker,
}
return form, params, nil
}
func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) {
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
return imageRegisterForm{}, api.ImageRegisterParams{}, err
}
form := imageRegisterForm{
Name: strings.TrimSpace(r.FormValue("name")),
RootfsPath: strings.TrimSpace(r.FormValue("rootfs_path")),
WorkSeedPath: strings.TrimSpace(r.FormValue("work_seed_path")),
KernelPath: strings.TrimSpace(r.FormValue("kernel_path")),
InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")),
ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")),
PackagesPath: strings.TrimSpace(r.FormValue("packages_path")),
Docker: r.FormValue("docker") == "on",
}
params := api.ImageRegisterParams{
Name: form.Name,
RootfsPath: form.RootfsPath,
WorkSeedPath: form.WorkSeedPath,
KernelPath: form.KernelPath,
InitrdPath: form.InitrdPath,
ModulesDir: form.ModulesDir,
PackagesPath: form.PackagesPath,
Docker: form.Docker,
}
return form, params, nil
}
type nilResponseWriter struct{}
func (nilResponseWriter) Header() http.Header { return http.Header{} }
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (nilResponseWriter) WriteHeader(statusCode int) {}
func writeJSON(w http.ResponseWriter, value any) error {
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(value)
}
func tailFile(path string, maxLines int) (string, error) {
if strings.TrimSpace(path) == "" {
return "", errors.New("log path is unavailable")
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if maxLines > 0 && len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
return strings.Join(lines, "\n"), nil
}
func findImage(images []model.Image, id string) model.Image {
for _, image := range images {
if image.ID == id {
return image
}
}
return model.Image{}
}
func endpointHref(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
return endpoint
}
return ""
}
func shortID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}
func sumInt64(values ...int64) int64 {
var total int64
for _, value := range values {
total += value
}
return total
}
func formatBytes(bytes int64) string {
const (
ki = 1024
mi = ki * 1024
gi = mi * 1024
ti = gi * 1024
)
switch {
case bytes >= ti:
return fmt.Sprintf("%.1f TiB", float64(bytes)/float64(ti))
case bytes >= gi:
return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gi))
case bytes >= mi:
return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mi))
case bytes >= ki:
return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(ki))
default:
return fmt.Sprintf("%d B", bytes)
}
}
func formatBytesCompact(bytes int64) string {
const (
ki = 1024
mi = ki * 1024
gi = mi * 1024
ti = gi * 1024
)
type unit struct {
size int64
suffix string
}
units := []unit{
{size: ti, suffix: "T"},
{size: gi, suffix: "G"},
{size: mi, suffix: "M"},
{size: ki, suffix: "K"},
}
abs := bytes
if abs < 0 {
abs = -abs
}
for _, candidate := range units {
if abs >= candidate.size {
value := float64(bytes) / float64(candidate.size)
if math.Abs(value-math.Round(value)) < 0.05 {
return fmt.Sprintf("%.0f%s", math.Round(value), candidate.suffix)
}
return fmt.Sprintf("%.1f%s", value, candidate.suffix)
}
}
return fmt.Sprintf("%dB", bytes)
}
func percentOf(used, total any) int {
totalValue := numericValue(total)
if totalValue <= 0 {
return 0
}
usedValue := numericValue(used)
percent := int(math.Round((usedValue / totalValue) * 100))
switch {
case percent < 0:
return 0
case percent > 100:
return 100
default:
return percent
}
}
func numericValue(value any) float64 {
switch typed := value.(type) {
case int:
return float64(typed)
case int8:
return float64(typed)
case int16:
return float64(typed)
case int32:
return float64(typed)
case int64:
return float64(typed)
case uint:
return float64(typed)
case uint8:
return float64(typed)
case uint16:
return float64(typed)
case uint32:
return float64(typed)
case uint64:
return float64(typed)
case float32:
return float64(typed)
case float64:
return typed
default:
return 0
}
}
func formatPercent(value float64) string {
return fmt.Sprintf("%.1f%%", value)
}
func relativeTime(ts time.Time) string {
if ts.IsZero() {
return "-"
}
delta := time.Since(ts)
switch {
case delta < time.Minute:
return "just now"
case delta < time.Hour:
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
case delta < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
default:
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
}
}
func formatBool(value bool) string {
if value {
return "yes"
}
return "no"
}
func stateClass(state model.VMState) string {
switch state {
case model.VMStateRunning:
return "running"
case model.VMStateStopped:
return "stopped"
case model.VMStateError:
return "error"
default:
return "created"
}
}