termd/internal/client/client.go
2026-07-02 22:13:49 -03:00

154 lines
4.5 KiB
Go

// 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"
}