banger/internal/webui/server.go
Thales Maciel 572bf32424
Remove runtime-bundle image dependencies
Hard-cut banger away from source-checkout runtime bundles as an implicit source of\nimage and host defaults. Managed images now own their full boot set,\nimage build starts from an existing registered image, and daemon startup\nno longer synthesizes a default image from host paths.\n\nResolve Firecracker from PATH or firecracker_bin, make SSH keys config-owned\nwith an auto-managed XDG default, replace the external name generator and\npackage manifests with Go code, and keep the vsock helper as a companion\nbinary instead of a user-managed runtime asset.\n\nUpdate the manual scripts, web/CLI forms, config surface, and docs around\nthe new build/manual flow and explicit image registration semantics.\n\nValidation: GOCACHE=/tmp/banger-gocache go test ./..., bash -n scripts/*.sh,\nand make build.
2026-03-21 18:34:53 -03:00

1229 lines
35 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
FromImage 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
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 {
return s.renderImageBuildPage(w, r, imageBuildForm{}, "")
}
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 {
return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "")
}
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})
}
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")),
FromImage: strings.TrimSpace(r.FormValue("from_image")),
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,
FromImage: form.FromImage,
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")),
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,
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"
}
}