- 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>
117 lines
3.6 KiB
Go
117 lines
3.6 KiB
Go
package incus
|
|
|
|
// The graphical console. Incus exposes a VM's SPICE display as a websocket: POST to the
|
|
// instance's console endpoint with type vga and it answers an operation with two secrets,
|
|
// one for the display stream and one for a control channel. Ham Lab dials both over Incus's
|
|
// unix socket and hands the display stream to the browser, where spice-html5 draws it.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
// SocketPath is where Incus's API listens; the daemon's account reaches it by being in
|
|
// incus-admin, which Install arranges.
|
|
func SocketPath() string {
|
|
if p := os.Getenv("INCUS_SOCKET"); p != "" {
|
|
return p
|
|
}
|
|
for _, p := range []string{"/var/lib/incus/unix.socket", "/run/incus/unix.socket"} {
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return "/var/lib/incus/unix.socket"
|
|
}
|
|
|
|
func (m *Manager) apiClient() *http.Client {
|
|
sock := SocketPath()
|
|
return &http.Client{Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, "unix", sock)
|
|
},
|
|
ForceAttemptHTTP2: false,
|
|
}}
|
|
}
|
|
|
|
// VGA opens the display and control websockets for a running VM. The caller closes both.
|
|
func (m *Manager) VGA(ctx context.Context, name string) (display, control *websocket.Conn, err error) {
|
|
if !ValidName(name) {
|
|
return nil, nil, errors.New("bad name")
|
|
}
|
|
client := m.apiClient()
|
|
body, _ := json.Marshal(map[string]any{"type": "vga", "width": 0, "height": 0})
|
|
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
req, _ := http.NewRequestWithContext(c, "POST", "http://incus/1.0/instances/"+name+"/console", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if os.IsPermission(errors.Unwrap(err)) || isPermission(err) {
|
|
return nil, nil, errors.New("Ham Lab's account can't reach Incus's socket; Finish setup on the Virtualization tab puts it in incus-admin")
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var op struct {
|
|
Error string `json:"error"`
|
|
Operation string `json:"operation"`
|
|
Metadata struct {
|
|
ID string `json:"id"`
|
|
Metadata struct {
|
|
FDs map[string]string `json:"fds"`
|
|
} `json:"metadata"`
|
|
} `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&op); err != nil {
|
|
return nil, nil, errors.New("unreadable answer from incus")
|
|
}
|
|
if op.Error != "" {
|
|
return nil, nil, errors.New(op.Error)
|
|
}
|
|
data, control0 := op.Metadata.Metadata.FDs["0"], op.Metadata.Metadata.FDs["control"]
|
|
if op.Metadata.ID == "" || data == "" {
|
|
return nil, nil, errors.New("incus gave no console; is the VM running?")
|
|
}
|
|
dial := func(secret string) (*websocket.Conn, error) {
|
|
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", op.Metadata.ID, secret)
|
|
ws, _, err := websocket.Dial(ctx, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
|
return ws, err
|
|
}
|
|
if control0 != "" {
|
|
if control, err = dial(control0); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
if display, err = dial(data); err != nil {
|
|
if control != nil {
|
|
control.Close(websocket.StatusInternalError, "")
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
display.SetReadLimit(64 << 20) // a SPICE frame can carry a whole screen
|
|
return display, control, nil
|
|
}
|
|
|
|
func isPermission(err error) bool {
|
|
var pe *os.PathError
|
|
if errors.As(err, &pe) {
|
|
return os.IsPermission(pe)
|
|
}
|
|
var oe *net.OpError
|
|
if errors.As(err, &oe) {
|
|
return os.IsPermission(oe.Err) || isPermission(oe.Err)
|
|
}
|
|
return false
|
|
}
|