internal/server/api/client/console.go
Ref: Size: 2.5 KiB History
package client
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/coder/websocket"
"github.com/a73x/eitri/internal/server/api/types"
)
// MintStreamTicket mints a one-time, short-TTL ticket for the SSE stream or a
// VM's console WebSocket. The ticket carries the minting credential's tenant
// and is the only thing eitri ever puts in a URL: neither an EventSource nor a
// WebSocket dial can carry a header, so the PAT authenticates THIS request and
// the ticket stands in for it on the stream.
func (c *Client) MintStreamTicket(ctx context.Context) (string, error) {
var out types.StreamTicketResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/stream-tickets", nil, &out); err != nil {
return "", err
}
if out.Ticket == "" {
return "", fmt.Errorf("client: POST /api/v1/stream-tickets: no ticket in the response")
}
return out.Ticket, nil
}
// consoleWSURL renders a VM's console endpoint as the URL to dial: the API's
// own origin with a WebSocket scheme, and the ticket as its only credential.
func consoleWSURL(baseURL, vmID, ticket string) string {
base := strings.TrimRight(baseURL, "/")
if rest, ok := strings.CutPrefix(base, "https://"); ok {
base = "wss://" + rest
} else if rest, ok := strings.CutPrefix(base, "http://"); ok {
base = "ws://" + rest
}
return base + "/api/v1/vms/" + url.PathEscape(vmID) + "/console/ws?ticket=" + url.QueryEscape(ticket)
}
// DialConsole attaches to a VM's serial console and hands back the raw byte
// pipe: reads are what the guest printed (the host replays its recent backlog
// on attach, then live output follows), writes are keystrokes to it. It is the
// browser's path exactly — mint a ticket, dial the WebSocket with it — so a
// caller watching a guest boot watches it through the same authenticated
// endpoint an operator does, with nothing on the host to log into.
//
// ctx bounds the whole session, not just the dial: cancelling it ends the
// stream. The caller closes the returned pipe.
func (c *Client) DialConsole(ctx context.Context, vmID string) (io.ReadWriteCloser, error) {
ticket, err := c.MintStreamTicket(ctx)
if err != nil {
return nil, err
}
//nolint:bodyclose // the handshake response body is the library's to close
conn, _, err := websocket.Dial(ctx, consoleWSURL(c.BaseURL, vmID, ticket), &websocket.DialOptions{HTTPClient: c.HTTP})
if err != nil {
return nil, fmt.Errorf("client: dial console for vm %s: %w", vmID, err)
}
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
}