package api // The Virtualization tab: LXC containers and KVM virtual machines through Incus, per // machine. Three page states — ready, repairable, installable, or unavailable with the // reason — and every action lands on the machine that was asked. Long work (install, create, // image downloads, publish) runs as a followed task; consoles are terminal sessions like // any other, so they reattach and work through the hub's proxy. import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "regexp" "strings" "time" "github.com/coder/websocket" "hamlab/internal/incus" "hamlab/internal/priv" "hamlab/internal/sensors" ) func readBody(r *http.Request, v any, max int64) error { return json.NewDecoder(io.LimitReader(r.Body, max)).Decode(v) } // virtOverview is the page: the machine's state, and when Incus answers, everything on it. func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) { st := s.virt.Detect(r.Context()) out := map[string]any{"status": st} if st.State == "ready" { ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) defer cancel() inst, err := s.virt.Instances(ctx) if err != nil { out["error"] = err.Error() } out["instances"] = inst imgs, _ := s.virt.Images(ctx) out["images"] = imgs out["pools"] = s.virt.Pools(ctx) out["networks"] = s.virt.Networks(ctx) } isos := s.virt.Library(s.virt.ISOsDir()) if inst, ok := out["instances"].([]incus.Instance); ok { users := s.isoUsers(inst) for i := range isos { isos[i].AttachedTo = users[isos[i].Name] } } out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": isos} jsonOK(w, 200, out) } func (s *Server) virtInstall(w http.ResponseWriter, r *http.Request) { var o incus.InstallOpts if err := readBody(r, &o, 16<<10); err != nil { jsonErr(w, 400, "bad request") return } st := s.virt.Detect(r.Context()) if st.State != "installable" && st.State != "repair" { jsonErr(w, 409, "nothing to install here: "+st.State) return } if err := s.virt.EnsureDirs(); err != nil { jsonErr(w, 500, err.Error()) return } script, lay, err := s.virt.InstallScript(st, o) if err != nil { jsonErr(w, 400, err.Error()) return } if err := s.virt.SaveLayout(lay); err != nil { jsonErr(w, 500, err.Error()) return } // kept beside the layout, so what was run is on record _ = os.WriteFile(filepath.Join(s.virt.Dir(), "install.sh"), []byte(script), 0o700) t, err := s.tasks.Start("virt:install", "install Incus", s.virt.ShellCmd(r.Context(), script)) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: install started as task %s — %s pool, %s (from %s)", t.ID, lay.Driver, lay.Subnet, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtReady(w http.ResponseWriter, r *http.Request) bool { if st := s.virt.Detect(r.Context()); st.State != "ready" { jsonErr(w, 409, "Incus isn't ready on this machine") return false } return true } // ---- images ---- func (s *Server) virtRemoteImages(w http.ResponseWriter, r *http.Request) { if !s.virtReady(w, r) { return } list, err := s.virt.Remote(r.Context(), r.URL.Query().Get("refresh") == "1") if err != nil { jsonErr(w, 502, err.Error()) return } if t := r.URL.Query().Get("type"); t == "container" || t == "virtual-machine" { var f []incus.Image for _, im := range list { if im.Type == t { f = append(f, im) } } list = f } if list == nil { list = []incus.Image{} } jsonOK(w, 200, map[string]any{"images": list}) } func (s *Server) virtImageDownload(w http.ResponseWriter, r *http.Request) { var body struct { Alias string `json:"alias"` } _ = readBody(r, &body, 4096) alias := strings.TrimSpace(body.Alias) if !incus.ValidImage(alias) || strings.Contains(alias, ":") { jsonErr(w, 400, "alias: like debian/13 or ubuntu/24.04/cloud") return } if !s.virtReady(w, r) { return } local := strings.ReplaceAll(alias, "/", "-") // copied into the local store under a flat alias, then exported to the library so the // file is Ham Lab's whether or not Incus keeps its cache script := `set -e; incus image copy "images:$1" local: --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"` t, err := s.tasks.Start("virt:image", "download "+alias, s.virt.ShellCmd(r.Context(), script, alias, local, s.virt.ImagesDir())) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: image %s download as task %s (from %s)", alias, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtImageImport(w http.ResponseWriter, r *http.Request) { var body struct { Path string `json:"path"` Alias string `json:"alias"` Type string `json:"type"` // container | virtual-machine } _ = readBody(r, &body, 8192) alias := strings.TrimSpace(body.Alias) if !incus.ValidImage(alias) || strings.Contains(alias, ":") { jsonErr(w, 400, "alias: letters, digits, dots, dashes") return } p := filepath.Clean(body.Path) st, err := os.Stat(p) if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() { jsonErr(w, 400, "path: a file on this machine") return } if !s.virtReady(w, r) { return } dest := filepath.Join(s.virt.ImagesDir(), filepath.Base(p)) var script string if body.Type == "virtual-machine" { // a bare disk image needs the metadata Incus wants beside it; a small tarball Ham Lab writes script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; META="$3/$4.metadata.tar.gz"; T=$(mktemp -d); printf 'architecture: %s\ncreation_date: %s\nproperties:\n description: "%s"\n os: custom\n' "$5" "$(date +%s)" "$4" > "$T/metadata.yaml"; tar -C "$T" -czf "$META" metadata.yaml; rm -rf "$T"; incus image import "$META" "$2" --alias "$4"; echo "imported $4 from $2"` } else { script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; incus image import "$2" --alias "$4"; echo "imported $4 from $2"` } t, err := s.tasks.Start("virt:image", "import "+alias, s.virt.ShellCmd(r.Context(), script, p, dest, s.virt.ImagesDir(), alias, incus.IncusArch())) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: image import %s from %s as task %s (from %s)", alias, p, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtImagePublish(w http.ResponseWriter, r *http.Request) { var body struct { Instance string `json:"instance"` Alias string `json:"alias"` } _ = readBody(r, &body, 4096) alias := strings.TrimSpace(body.Alias) if !incus.ValidName(body.Instance) || !incus.ValidImage(alias) || strings.Contains(alias, ":") { jsonErr(w, 400, "an instance and an alias") return } if !s.virtReady(w, r) { return } script := `set -e; incus publish "$1" --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"` t, err := s.tasks.Start("virt:image", "publish "+body.Instance+" as "+alias, s.virt.ShellCmd(r.Context(), script, body.Instance, alias, s.virt.ImagesDir())) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: publish %s as %s, task %s (from %s)", body.Instance, alias, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtImageDelete(w http.ResponseWriter, r *http.Request) { var body struct { Ref string `json:"ref"` } _ = readBody(r, &body, 4096) if !incus.ValidImage(body.Ref) { jsonErr(w, 400, "bad image") return } if !s.virtReady(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() if out, err := s.virt.Cmd(ctx, "image", "delete", body.Ref).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: image %s deleted (from %s)", body.Ref, clientIP(r)) w.WriteHeader(204) } // ---- instances ---- func (s *Server) virtCreate(w http.ResponseWriter, r *http.Request) { var sp incus.CreateSpec if err := readBody(r, &sp, 512<<10); err != nil { jsonErr(w, 400, "bad request") return } st := s.virt.Detect(r.Context()) if st.State != "ready" { jsonErr(w, 409, "Incus isn't ready on this machine") return } args, err := incus.CreateArgs(sp, st.KVM) if err != nil { jsonErr(w, 400, err.Error()) return } if inst, err := s.virt.Instances(r.Context()); err == nil { for _, in := range inst { if in.Name == sp.Name { jsonErr(w, 409, "there is already an instance called "+sp.Name) return } } } kind := "container" if sp.VM { kind = "VM" } t, err := s.tasks.Start("virt:create", fmt.Sprintf("create %s %s", kind, sp.Name), s.virt.Cmd(r.Context(), args...)) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: create %s %s from %s as task %s (from %s)", kind, sp.Name, sp.Image, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtInstance(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !incus.ValidName(name) { jsonErr(w, 400, "bad name") return } list, err := s.virt.Instances(r.Context()) if err != nil { jsonErr(w, 502, err.Error()) return } for _, in := range list { if in.Name == name { jsonOK(w, 200, in) return } } jsonErr(w, 404, "no such instance") } // virtAction is start / stop / restart / kill / delete, and the two consoles. func (s *Server) virtAction(w http.ResponseWriter, r *http.Request) { name, action := r.PathValue("name"), r.PathValue("action") if !incus.ValidName(name) { jsonErr(w, 400, "bad name") return } if !s.virtReady(w, r) { return } var args []string switch action { case "start": args = []string{"start", name} case "stop": args = []string{"stop", name} case "restart": args = []string{"restart", name} case "kill": args = []string{"stop", "--force", name} case "delete": var body struct { Confirm string `json:"confirm"` Force bool `json:"force"` } _ = readBody(r, &body, 4096) if body.Confirm != name { jsonErr(w, 400, "type the instance's name to delete it") return } args = []string{"delete", name} if body.Force { args = []string{"delete", "--force", name} } case "console", "exec": s.virtTerminal(w, r, name, action) return default: jsonErr(w, 400, "unknown action") return } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: %s %s (from %s)", action, name, clientIP(r)) w.WriteHeader(204) } // virtTerminal opens the serial console or a shell as a terminal session. func (s *Server) virtTerminal(w http.ResponseWriter, r *http.Request, name, kind string) { var argv []string title := "" if kind == "console" { argv = s.virt.Argv(r.Context(), "console", name) title = "console " + name } else { argv = s.virt.Argv(r.Context(), "exec", name, "--", "sh", "-c", "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi") title = "sh " + name } sess, err := s.mgr.CreateCmd("", title, argv) if err != nil { jsonErr(w, 500, err.Error()) return } log.Printf("virt: %s of %s as terminal %s (from %s)", kind, name, sess.ID, clientIP(r)) jsonOK(w, 200, map[string]string{"id": sess.ID, "name": title}) } func (s *Server) virtRename(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body struct { Name string `json:"name"` } _ = readBody(r, &body, 4096) if !incus.ValidName(name) || !incus.ValidName(body.Name) { jsonErr(w, 400, "bad name") return } if !s.virtReady(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, "rename", name, body.Name).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: renamed %s to %s (from %s)", name, body.Name, clientIP(r)) w.WriteHeader(204) } // virtConfig sets limits, the snapshot schedule, or a description — the config keys the page // edits, each validated for its shape. An empty value unsets the key. func (s *Server) virtConfig(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body map[string]string if err := readBody(r, &body, 16<<10); err != nil || !incus.ValidName(name) { jsonErr(w, 400, "bad request") return } var set, unset []string desc, hasDesc := body["description"] delete(body, "description") if hasDesc && (len(desc) > 200 || strings.ContainsAny(desc, "\n\r")) { jsonErr(w, 400, "bad value for description") return } for k, v := range body { v = strings.TrimSpace(v) ok := false switch k { case "limits.cpu": ok = incus.ValidCPU(v) case "limits.memory": ok = incus.ValidSize(v) case "snapshots.schedule": ok = incus.ValidCron(v) case "snapshots.expiry": ok = incus.ValidExpiry(v) case "boot.autostart": ok = v == "" || v == "true" || v == "false" } if !ok { jsonErr(w, 400, "bad value for "+k) return } if v == "" { unset = append(unset, k) } else { set = append(set, k+"="+v) } } if !s.virtReady(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if len(set) > 0 { if out, err := s.virt.Cmd(ctx, append([]string{"config", "set", name}, set...)...).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } } for _, k := range unset { _, _ = s.virt.Cmd(ctx, "config", "unset", name, k).CombinedOutput() // unsetting what isn't set is fine } if hasDesc { // the description is a field of the instance, not a config key, so it goes in through the API patch, _ := json.Marshal(map[string]string{"description": strings.TrimSpace(desc)}) if out, err := s.virt.Cmd(ctx, "query", "-X", "PATCH", "-d", string(patch), "/1.0/instances/"+name).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } } log.Printf("virt: config %s set %v unset %v (from %s)", name, set, unset, clientIP(r)) w.WriteHeader(204) } func (s *Server) virtSnapshotCreate(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body struct { Name string `json:"name"` } _ = readBody(r, &body, 4096) snap := strings.TrimSpace(body.Name) if snap == "" { snap = time.Now().Format("2006-01-02_15.04.05") } if !incus.ValidName(name) || !incus.ValidSnap(snap) { jsonErr(w, 400, "bad name") return } if !s.virtReady(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, "snapshot", "create", name, snap).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: snapshot %s/%s (from %s)", name, snap, clientIP(r)) jsonOK(w, 200, map[string]string{"name": snap}) } func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) { name, snap, action := r.PathValue("name"), r.PathValue("snap"), r.PathValue("action") if !incus.ValidName(name) || !incus.ValidSnap(snap) { jsonErr(w, 400, "bad name") return } var args []string switch action { case "restore": args = []string{"snapshot", "restore", name, snap} case "delete": args = []string{"snapshot", "delete", name, snap} default: jsonErr(w, 400, "unknown action") return } if !s.virtReady(w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: snapshot %s %s/%s (from %s)", action, name, snap, clientIP(r)) w.WriteHeader(204) } // ---- the graphical console ---- // virtVGA bridges the browser to the VM's SPICE display: the browser's websocket on one side, // Incus's display websocket (dialled over its unix socket) on the other, frames copied both // ways. spice-html5 in the page speaks SPICE; Ham Lab only carries it. func (s *Server) virtVGA(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !incus.ValidName(name) { jsonErr(w, 400, "bad name") return } // spice-html5 asks for the "binary" subprotocol and gives up unless the server agrees to it conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled, Subprotocols: []string{"binary"}}) if err != nil { return } conn.SetReadLimit(64 << 20) ctx, cancel := context.WithCancel(r.Context()) defer cancel() display, release, err := s.virt.VGAChannel(ctx, name) if err != nil { log.Printf("virt: vga %s refused: %v (from %s)", name, err, clientIP(r)) conn.Close(websocket.StatusInternalError, truncate(err.Error(), 120)) return } defer release() defer display.Close(websocket.StatusNormalClosure, "") go func() { defer cancel() for { _, data, err := display.Read(ctx) if err != nil { return } if err := conn.Write(ctx, websocket.MessageBinary, data); err != nil { return } } }() for { _, data, err := conn.Read(ctx) if err != nil { break } if err := display.Write(ctx, websocket.MessageBinary, data); err != nil { break } } cancel() conn.Close(websocket.StatusNormalClosure, "") } func truncate(s string, n int) string { if len(s) > n { return s[:n] } return s } // ---- ISOs ---- func (s *Server) isoPath(name string) (string, bool) { if !incus.ValidISOName(name) { return "", false } return filepath.Join(s.virt.ISOsDir(), name), true } // isoUsers says which instances have each ISO file attached, by its file name. func (s *Server) isoUsers(list []incus.Instance) map[string][]string { out := map[string][]string{} dir := s.virt.ISOsDir() + "/" for _, in := range list { for _, d := range in.Devices { if d["type"] == "disk" && strings.HasPrefix(d["source"], dir) { n := strings.TrimPrefix(d["source"], dir) out[n] = append(out[n], in.Name) } } } return out } // userShell runs a small script as the daemon's own account — file work in Ham Lab's folder // needs no root — with positional parameters. func userShell(script string, params ...string) *exec.Cmd { return exec.Command("sh", append([]string{"-c", script, "hamlab"}, params...)...) } var urlRe = regexp.MustCompile(`^https?://[^\s"'<>]+$`) func (s *Server) virtISOFetch(w http.ResponseWriter, r *http.Request) { var body struct { URL string `json:"url"` SHA256 string `json:"sha256"` Name string `json:"name"` } _ = readBody(r, &body, 8192) u := strings.TrimSpace(body.URL) if !urlRe.MatchString(u) { jsonErr(w, 400, "url: http or https") return } name := strings.TrimSpace(body.Name) if name == "" { if pu, err := url.Parse(u); err == nil { name = path.Base(pu.Path) } } dest, ok := s.isoPath(name) if !ok { jsonErr(w, 400, "name: a file name ending in .iso") return } sum := strings.ToLower(strings.TrimSpace(body.SHA256)) if sum != "" && !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sum) { jsonErr(w, 400, "sha256: 64 hex digits") return } if _, err := os.Stat(dest); err == nil { jsonErr(w, 409, name+" is already in the library") return } if err := s.virt.EnsureDirs(); err != nil { jsonErr(w, 500, err.Error()) return } script := `set -e; echo "fetching $1"; echo " to $2"; curl -fL --retry 3 -sS -o "$2.part" "$1"; if [ -n "$3" ]; then echo "$3 $2.part" | sha256sum -c - || { rm -f "$2.part"; exit 1; }; fi; mv "$2.part" "$2"; ls -l "$2" | awk '{print " " $5 " bytes"}'` t, err := s.tasks.Start("virt:iso", "fetch "+name, userShell(script, u, dest, sum)) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: iso fetch %s as task %s (from %s)", u, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtISOImport(w http.ResponseWriter, r *http.Request) { var body struct { Path string `json:"path"` } _ = readBody(r, &body, 8192) p := filepath.Clean(body.Path) st, err := os.Stat(p) if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() { jsonErr(w, 400, "path: a file on this machine") return } dest, ok := s.isoPath(filepath.Base(p)) if !ok { jsonErr(w, 400, "the file has to be a .iso or .img") return } if _, err := os.Stat(dest); err == nil { jsonErr(w, 409, filepath.Base(p)+" is already in the library") return } if err := s.virt.EnsureDirs(); err != nil { jsonErr(w, 500, err.Error()) return } script := `set -e; echo "copying $1"; echo " to $2"; cp --reflink=auto "$1" "$2.part" && mv "$2.part" "$2"; ls -l "$2" | awk '{print " " $5 " bytes"}'` cmd := userShell(script, p, dest) if f, err := os.Open(p); err != nil && priv.Available() { // readable only by root: copy as root and hand it over argv := priv.Wrap("sh", "-c", script+`; chown "$3" "$2"`, "hamlab", p, dest, s.user) cmd = exec.Command(argv[0], argv[1:]...) } else if err == nil { f.Close() } t, err := s.tasks.Start("virt:iso", "import "+filepath.Base(p), cmd) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: iso import %s as task %s (from %s)", p, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtISODelete(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` } _ = readBody(r, &body, 4096) p, ok := s.isoPath(body.Name) if !ok { jsonErr(w, 400, "bad name") return } if list, err := s.virt.Instances(r.Context()); err == nil { if users := s.isoUsers(list)[body.Name]; len(users) > 0 { jsonErr(w, 409, "attached to "+strings.Join(users, ", ")+" — detach it first") return } } if err := os.Remove(p); err != nil { jsonErr(w, 404, "no such ISO") return } log.Printf("virt: iso %s deleted (from %s)", body.Name, clientIP(r)) w.WriteHeader(204) } func (s *Server) virtISORename(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` To string `json:"to"` } _ = readBody(r, &body, 4096) from, ok1 := s.isoPath(body.Name) to, ok2 := s.isoPath(body.To) if !ok1 || !ok2 { jsonErr(w, 400, "bad name") return } if list, err := s.virt.Instances(r.Context()); err == nil { if users := s.isoUsers(list)[body.Name]; len(users) > 0 { jsonErr(w, 409, "attached to "+strings.Join(users, ", ")+" — detach it first") return } } if _, err := os.Stat(to); err == nil { jsonErr(w, 409, body.To+" already exists") return } if err := os.Rename(from, to); err != nil { jsonErr(w, 404, "no such ISO") return } w.WriteHeader(204) } func (s *Server) instanceByName(ctx context.Context, name string) (*incus.Instance, error) { list, err := s.virt.Instances(ctx) if err != nil { return nil, err } for i := range list { if list[i].Name == name { return &list[i], nil } } return nil, fmt.Errorf("no instance called %s", name) } // virtISOAttach puts an ISO in a VM's drive — live or stopped, Incus takes either — as a ROM // device, first in the boot order when asked. func (s *Server) virtISOAttach(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body struct { ISO string `json:"iso"` BootFirst bool `json:"boot_first"` } _ = readBody(r, &body, 4096) p, ok := s.isoPath(body.ISO) if !incus.ValidName(name) || !ok { jsonErr(w, 400, "bad name") return } if _, err := os.Stat(p); err != nil { jsonErr(w, 404, "no such ISO in the library") return } if !s.virtReady(w, r) { return } in, err := s.instanceByName(r.Context(), name) if err != nil { jsonErr(w, 404, err.Error()) return } if in.Type != "virtual-machine" { jsonErr(w, 409, "only a virtual machine takes an ISO") return } dev := "" for i := 0; i < 10; i++ { if _, taken := in.Devices[fmt.Sprintf("iso%d", i)]; !taken { dev = fmt.Sprintf("iso%d", i) break } } if dev == "" { jsonErr(w, 409, "ten ISOs are already attached") return } args := []string{"config", "device", "add", name, dev, "disk", "source=" + p} if body.BootFirst { args = append(args, "boot.priority=10") } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: %s attached to %s as %s (from %s)", body.ISO, name, dev, clientIP(r)) jsonOK(w, 200, map[string]string{"device": dev}) } func (s *Server) virtISODetach(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body struct { Device string `json:"device"` } _ = readBody(r, &body, 4096) if !incus.ValidName(name) || !incus.ValidDevice(body.Device) { jsonErr(w, 400, "bad name") return } if !s.virtReady(w, r) { return } in, err := s.instanceByName(r.Context(), name) if err != nil { jsonErr(w, 404, err.Error()) return } d, ok := in.Devices[body.Device] if !ok || d["type"] != "disk" || !strings.HasPrefix(d["source"], s.virt.ISOsDir()+"/") { jsonErr(w, 409, body.Device+" isn't an ISO from the library") return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, "config", "device", "remove", name, body.Device).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: %s detached from %s (from %s)", body.Device, name, clientIP(r)) w.WriteHeader(204) } func (s *Server) virtFromISO(w http.ResponseWriter, r *http.Request) { var sp incus.FromISOSpec if err := readBody(r, &sp, 16<<10); err != nil { jsonErr(w, 400, "bad request") return } st := s.virt.Detect(r.Context()) if st.State != "ready" { jsonErr(w, 409, "Incus isn't ready on this machine") return } script, params, err := incus.FromISOScript(sp, st.KVM) if err != nil { jsonErr(w, 400, err.Error()) return } p, _ := s.isoPath(sp.ISO) if _, err := os.Stat(p); err != nil { jsonErr(w, 404, "no such ISO in the library") return } params[1] = p if _, err := s.instanceByName(r.Context(), sp.Name); err == nil { jsonErr(w, 409, "there is already an instance called "+sp.Name) return } t, err := s.tasks.Start("virt:create", "create VM "+sp.Name+" from "+sp.ISO, s.virt.ShellCmd(r.Context(), script, params...)) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: VM %s from %s as task %s (from %s)", sp.Name, sp.ISO, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } // ---- mounts: disk devices other than root ---- func (s *Server) virtMountAdd(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body struct { Device string `json:"device"` Source string `json:"source"` Path string `json:"path"` ReadOnly bool `json:"readonly"` Shift bool `json:"shift"` } _ = readBody(r, &body, 8192) src, dst := filepath.Clean(body.Source), filepath.Clean(body.Path) dev := strings.TrimSpace(body.Device) if dev == "" { dev = "mnt-" + sensors.Seg(filepath.Base(src)) } switch { case !incus.ValidName(name): jsonErr(w, 400, "bad name") return case !incus.ValidDevice(dev): jsonErr(w, 400, "device: letters, digits, dashes") return case !filepath.IsAbs(src) || !filepath.IsAbs(dst) || dst == "/": jsonErr(w, 400, "source and path: absolute paths; path not /") return } if _, err := os.Stat(src); err != nil && os.IsNotExist(err) { jsonErr(w, 404, src+" doesn't exist on this machine") return } if !s.virtReady(w, r) { return } in, err := s.instanceByName(r.Context(), name) if err != nil { jsonErr(w, 404, err.Error()) return } if _, taken := in.Devices[dev]; taken { jsonErr(w, 409, "there is already a device called "+dev) return } args := []string{"config", "device", "add", name, dev, "disk", "source=" + src, "path=" + dst} if body.ReadOnly { args = append(args, "readonly=true") } if body.Shift && in.Type == "container" { args = append(args, "shift=true") } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: %s: mounted %s at %s as %s (from %s)", name, src, dst, dev, clientIP(r)) jsonOK(w, 200, map[string]string{"device": dev}) } func (s *Server) virtMountRemove(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body struct { Device string `json:"device"` } _ = readBody(r, &body, 4096) if !incus.ValidName(name) || !incus.ValidDevice(body.Device) || body.Device == "root" { jsonErr(w, 400, "bad name") return } if !s.virtReady(w, r) { return } in, err := s.instanceByName(r.Context(), name) if err != nil { jsonErr(w, 404, err.Error()) return } if d, ok := in.Devices[body.Device]; !ok || d["type"] != "disk" { jsonErr(w, 409, body.Device+" isn't a disk device of "+name) return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if out, err := s.virt.Cmd(ctx, "config", "device", "remove", name, body.Device).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } log.Printf("virt: %s: device %s removed (from %s)", name, body.Device, clientIP(r)) w.WriteHeader(204) } // ---- exports ---- func (s *Server) virtExports(w http.ResponseWriter, r *http.Request) { c := s.virt.ExportConfig() out := map[string]any{"config": c, "cron": c.CronExpr(), "last": s.virt.LastExportRun(), "files": s.virt.ExportFiles(), "dir": s.virt.ExportsDir(), "cron_file": incus.CronFile} if c.Enabled { out["next"] = c.NextRun(time.Now()) } jsonOK(w, 200, out) } func (s *Server) virtExportsPut(w http.ResponseWriter, r *http.Request) { var c incus.ExportConfig if err := readBody(r, &c, 16<<10); err != nil { jsonErr(w, 400, "bad request") return } if err := s.virt.SaveExportConfig(c); err != nil { jsonErr(w, 400, err.Error()) return } exe, err := os.Executable() if err == nil { exe, _ = filepath.EvalSymlinks(exe) } if c.Enabled { err = s.storage.WriteRootFile(r.Context(), incus.CronFile, c.CronText(s.user, exe, s.cfg.Path())) } else { err = s.storage.RemoveRootFile(r.Context(), incus.CronFile) } if err != nil { jsonErr(w, 500, "schedule saved, but the cron file could not be written: "+err.Error()) return } log.Printf("virt: exports %v at %02d:%02d keep %d (from %s)", c.Enabled, c.Hour, c.Minute, c.Keep, clientIP(r)) w.WriteHeader(204) } func (s *Server) virtExportsRun(w http.ResponseWriter, r *http.Request) { var body struct { Instance string `json:"instance"` } _ = readBody(r, &body, 4096) if body.Instance != "" && !incus.ValidName(body.Instance) { jsonErr(w, 400, "bad name") return } if !s.virtReady(w, r) { return } exe, err := os.Executable() if err != nil { jsonErr(w, 500, err.Error()) return } args := []string{"-config", s.cfg.Path(), "virt-export"} title := "export every instance" if body.Instance != "" { args = append(args, body.Instance) title = "export " + body.Instance } t, err := s.tasks.Start("virt:export", title, exec.Command(exe, args...)) if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("virt: %s as task %s (from %s)", title, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } func (s *Server) virtExportDelete(w http.ResponseWriter, r *http.Request) { var body struct { Instance string `json:"instance"` Name string `json:"name"` } _ = readBody(r, &body, 4096) if !incus.ValidName(body.Instance) || body.Name != filepath.Base(body.Name) || !strings.HasSuffix(body.Name, ".tar.gz") { jsonErr(w, 400, "bad name") return } if err := os.Remove(filepath.Join(s.virt.ExportsDir(), body.Instance, body.Name)); err != nil { jsonErr(w, 404, "no such export") return } log.Printf("virt: export %s/%s deleted (from %s)", body.Instance, body.Name, clientIP(r)) w.WriteHeader(204) } // virtSSHKeys is the daemon account's public keys, for a cloud-init that lets him straight in. func (s *Server) virtSSHKeys(w http.ResponseWriter, _ *http.Request) { home, _ := os.UserHomeDir() seen := map[string]bool{} keys := []string{} add := func(line string) { f := strings.Fields(line) if len(f) < 2 || !strings.HasPrefix(f[0], "ssh-") && !strings.HasPrefix(f[0], "ecdsa-") && !strings.HasPrefix(f[0], "sk-") { return } k := f[0] + " " + f[1] if seen[k] { return } seen[k] = true keys = append(keys, strings.TrimSpace(line)) } if b, err := os.ReadFile(filepath.Join(home, ".ssh", "authorized_keys")); err == nil { for _, l := range strings.Split(string(b), "\n") { add(l) } } pubs, _ := filepath.Glob(filepath.Join(home, ".ssh", "*.pub")) for _, p := range pubs { if b, err := os.ReadFile(p); err == nil { add(string(b)) } } jsonOK(w, 200, map[string]any{"keys": keys, "user": s.user}) }