Virtualization phase 2: ISOs, New VM from ISO, the VGA console in the browser, nightly exports, mounts, instance sensors
- Library: an ISO shelf in virt/isos: upload (the Files uploader), fetch by URL with an optional sha256, import a file from the machine by copying it in, rename, delete (refused while attached), attach to a VM as a CD-ROM live or stopped with boot-first, eject, and New VM from ISO: an empty VM with the ISO as first boot device, Secure Boot on or off. - The VGA console: Ham Lab asks Incus for the VM's SPICE console over its unix socket and bridges the display websocket to the browser, where spice-html5 (vendored from upstream b372aab, LGPL, loaded on demand) draws it; Ctrl+Alt+Del, fit-to-window. - Exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a cron line in /etc/cron.d/hamlab-virt calling hamlab virt-export, settings and the last run as files beside the data; Export now per instance or for all. - Disks tab per instance: the root disk, attached ISOs with Eject, bind mounts in and out (shift for containers, virtiofs for VMs). - instance.* sensors (running, down, state, cpu, mem) and the VM-or-container-down alert default, offered once to installs seeded before it existed. - cloud-init: Use my SSH keys writes a cloud-config from the account's public keys. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
66498a58c5
commit
43ebacdd92
+605
-1
@@ -13,12 +13,20 @@ import (
|
||||
"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 {
|
||||
@@ -42,7 +50,14 @@ func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) {
|
||||
out["pools"] = s.virt.Pools(ctx)
|
||||
out["networks"] = s.virt.Networks(ctx)
|
||||
}
|
||||
out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": s.virt.Library(s.virt.ISOsDir())}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -500,3 +515,592 @@ func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user