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) 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 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 ImageRegisterForm imageRegisterForm LogText string VMCreateOperation *api.VMCreateOperation 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/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 /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI)) 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) 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) 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) 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) 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" } }