internal/server/api/console.go
Ref: Size: 3.5 KiB History
package api
import (
"context"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/coder/websocket"
)
// ConsoleDialer opens a raw byte pipe to a VM's serial console on its host's
// live sync connection (consumer-owned; the concrete implementation is
// *syncsvc.Service, wired by main via SetConsoleDialer).
type ConsoleDialer interface {
OpenConsole(ctx context.Context, hostID, vmID string) (io.ReadWriteCloser, error)
}
// SetConsoleDialer wires the console broker. Called once by main after the
// sync service is constructed; a nil dialer leaves the endpoint returning 503.
func (a *API) SetConsoleDialer(d ConsoleDialer) { a.console = d }
// consoleOpenTimeout bounds resolving + handshaking the agent-side stream.
const consoleOpenTimeout = 10 * time.Second
// handleConsoleWS bridges a browser WebSocket to a VM serial console.
// EventSource-style auth: browsers cannot set headers on a WebSocket dial, so
// the request carries a one-time short-TTL ticket minted via the
// user-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — the
// caller's PAT/session never appears in a URL. The ticket carries the minting
// principal's tenant, which gates which VM the console may attach to.
func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) {
tenant, ok := a.tickets.consume(r.URL.Query().Get("ticket"))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if a.console == nil {
http.Error(w, "console unavailable", http.StatusServiceUnavailable)
return
}
id := r.PathValue("id")
vm, ok := a.vmByID(id)
if !ok || vm.DeletedAt != nil || !mayActAs(Principal{Tenant: tenant}, vm.Tenant) {
// GetVM does not filter tombstones; a console to a VM being decommissioned
// or deleted must not open (the agent leg would refuse it anyway). A VM in
// another tenant answers identically to a missing one — existence is not
// leaked across tenants (matches the mayActAs 404 convention).
http.Error(w, "not found", http.StatusNotFound)
return
}
// Upgrade FIRST, then dial the agent. A pre-upgrade HTTP error body is
// invisible to browser WebSocket JS, but a close frame's reason is
// readable (event.reason in onclose) — and the spec requires the UI to
// show WHY ("host offline"). Auth/404 failures above stay pre-upgrade:
// they carry no operator-facing reason.
c, err := websocket.Accept(w, r, nil) // default same-origin check
if err != nil {
return
}
defer c.CloseNow()
openCtx, cancel := context.WithTimeout(r.Context(), consoleOpenTimeout)
stream, err := a.console.OpenConsole(openCtx, vm.HostID, id)
cancel()
if err != nil {
reason := "console unavailable: " + err.Error()
if len(reason) > 120 { // close reasons are capped at 123 bytes
// The byte trim can bisect a multi-byte rune and RFC 6455 requires
// close reasons to be valid UTF-8 — drop any trailing fragment.
reason = strings.ToValidUTF8(reason[:120], "")
}
_ = c.Close(websocket.StatusInternalError, reason)
return
}
nc := websocket.NetConn(r.Context(), c, websocket.MessageBinary)
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(stream, nc); stream.Close(); done <- struct{}{} }()
go func() { _, _ = io.Copy(nc, stream); nc.Close(); done <- struct{}{} }()
<-done
<-done
_ = c.Close(websocket.StatusNormalClosure, "")
// Unconditional: after websocket.Accept hijacks the connection, net/http
// never cancels r.Context(), so there is no cancellation to filter on.
slog.Debug("console session closed", "vm", id)
}