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 }