Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1108 lines
33 KiB
Go
1108 lines
33 KiB
Go
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, control, err := s.virt.VGA(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
|
|
}
|
|
log.Printf("virt: vga console of %s opened (from %s)", name, clientIP(r))
|
|
defer display.Close(websocket.StatusNormalClosure, "")
|
|
if control != nil {
|
|
defer control.Close(websocket.StatusNormalClosure, "")
|
|
go func() { // the control channel carries nothing we send; keep it open and drain it
|
|
for {
|
|
if _, _, err := control.Read(ctx); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
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, "")
|
|
log.Printf("virt: vga console of %s closed", name)
|
|
}
|
|
|
|
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})
|
|
}
|