// Package client is the REST client used by every CLI subcommand (and the // attach client for its HTTP-side needs). package client import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "termd/internal/api" ) // Client talks to a termd daemon. type Client struct { base string // http://host:port, no trailing slash token string hc *http.Client } // New builds a client for addr, which is "host:port", a full http(s) URL, or // "unix:///path/to.sock". func New(addr, token string) *Client { hc := &http.Client{} base := addr if path, ok := strings.CutPrefix(addr, "unix://"); ok { hc.Transport = &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", path) }, } base = "http://termd" // dummy host; the dialer ignores it } else if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") { base = "http://" + addr } return &Client{base: strings.TrimRight(base, "/"), token: token, hc: hc} } // BaseURL returns the HTTP base URL (ws dials derive from it). func (c *Client) BaseURL() string { return c.base } // Token returns the bearer token, empty if none. func (c *Client) Token() string { return c.token } // HTTPClient returns the underlying HTTP client (carries the unix-socket // transport when one is configured). func (c *Client) HTTPClient() *http.Client { return c.hc } func (c *Client) do(ctx context.Context, method, path string, body, out any) error { var rd io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return fmt.Errorf("encoding request: %w", err) } rd = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd) if err != nil { return err } if body != nil { req.Header.Set("Content-Type", "application/json") } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { var apiErr api.Error data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if json.Unmarshal(data, &apiErr) == nil && apiErr.Error != "" { msg := apiErr.Error if apiErr.Modes != nil { msg += fmt.Sprintf(" (modes: mouse=%s encoding=%s)", apiErr.Modes.Mouse, apiErr.Modes.MouseEncoding) } return fmt.Errorf("%s: %s", resp.Status, msg) } return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data))) } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("decoding response: %w", err) } } return nil } func (c *Client) Create(ctx context.Context, req api.CreateSessionRequest) (api.SessionInfo, error) { var info api.SessionInfo err := c.do(ctx, http.MethodPost, "/sessions", req, &info) return info, err } func (c *Client) List(ctx context.Context) (api.SessionList, error) { var list api.SessionList err := c.do(ctx, http.MethodGet, "/sessions", nil, &list) return list, err } func (c *Client) Info(ctx context.Context, id string) (api.SessionInfo, error) { var info api.SessionInfo err := c.do(ctx, http.MethodGet, "/sessions/"+url.PathEscape(id), nil, &info) return info, err } func (c *Client) Delete(ctx context.Context, id, signal string) error { path := "/sessions/" + url.PathEscape(id) if signal != "" { path += "?signal=" + url.QueryEscape(signal) } return c.do(ctx, http.MethodDelete, path, nil, nil) } func (c *Client) Resize(ctx context.Context, id string, cols, rows int) error { return c.do(ctx, http.MethodPost, "/sessions/"+url.PathEscape(id)+"/resize", api.ResizeRequest{Cols: cols, Rows: rows}, nil) } func (c *Client) Input(ctx context.Context, id string, items []api.InputItem) (api.InputResponse, error) { var resp api.InputResponse err := c.do(ctx, http.MethodPost, "/sessions/"+url.PathEscape(id)+"/input", api.InputRequest{Input: items}, &resp) return resp, err } func (c *Client) Mouse(ctx context.Context, id string, req api.MouseRequest) error { return c.do(ctx, http.MethodPost, "/sessions/"+url.PathEscape(id)+"/mouse", req, nil) } func (c *Client) Screen(ctx context.Context, id string, raw bool) (api.Screen, error) { path := "/sessions/" + url.PathEscape(id) + "/screen" if raw { path += "?format=raw" } var scr api.Screen err := c.do(ctx, http.MethodGet, path, nil, &scr) return scr, err } // AttachURL returns the websocket URL for a session. func (c *Client) AttachURL(id string) string { return c.base + "/sessions/" + url.PathEscape(id) + "/attach" }