a73x

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
}