// Package client is a small Go client for Housecarl's REST API, used by the // housecarl CLI. package client import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "time" "housecarl/internal/api" "housecarl/internal/version" ) // Client talks to a Housecarl server. type Client struct { BaseURL string Token string HTTP *http.Client // DryRun previews changes instead of making them. DryRun bool } // Options configure New. type Options struct { Server string // https://... or unix:///run/housecarl/housecarl.sock Token string Insecure bool // skip TLS certificate checks CACert string // PEM file with an extra CA to trust Timeout time.Duration } // New makes a client. func New(o Options) (*Client, error) { tr := http.DefaultTransport.(*http.Transport).Clone() base := strings.TrimSuffix(o.Server, "/") if path, ok := SocketPath(o.Server); ok { tr.Proxy = nil tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", path) } base = "http://housecarld" } else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, fmt.Errorf("invalid server URL %q (use e.g. https://firewall.example.com:8070, or unix:///run/housecarl/housecarl.sock)", o.Server) } tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // the user's choice if o.CACert != "" { pem, err := os.ReadFile(o.CACert) if err != nil { return nil, err } pool, err := x509.SystemCertPool() if err != nil || pool == nil { pool = x509.NewCertPool() } if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("no certificates in %s", o.CACert) } tc.RootCAs = pool } tr.TLSClientConfig = tc timeout := o.Timeout if timeout == 0 { timeout = 2 * time.Minute } return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil } // SocketPath returns the path of a unix:// URL. func SocketPath(server string) (string, bool) { path, ok := strings.CutPrefix(server, "unix://") if !ok || !strings.HasPrefix(path, "/") { return "", false } return path, true } // Error is an API error. type Error struct { Status int Info api.ErrorInfo } func (e *Error) Error() string { if e.Info.Message != "" { return e.Info.Message } return fmt.Sprintf("HTTP %d", e.Status) } // Do sends a request: body as JSON, the JSON answer into out (if not nil; // a *string gets the answer as text). // Changes get dry_run=1 when the client's DryRun is set. func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error { if c.DryRun && method != http.MethodGet { if query == nil { query = url.Values{} } query.Set("dry_run", "1") } u := c.BaseURL + api.Prefix + path if len(query) > 0 { u += "?" + query.Encode() } var rd io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } rd = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, u, rd) if err != nil { return err } if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "housecarl-cli/"+version.Short()) if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } resp, err := c.HTTP.Do(req) if err != nil { if c.BaseURL == "http://housecarld" { return fmt.Errorf("can't reach housecarld on its local socket: %w (is housecarld running? 'systemctl status housecarl')", err) } return err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 400 { e := &Error{Status: resp.StatusCode} var eb api.ErrorBody if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" { e.Info = eb.Error } else { e.Info.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } return e } if text, ok := out.(*string); ok { *text = string(b) return nil } if out != nil && len(b) > 0 { if err := json.Unmarshal(b, out); err != nil { return fmt.Errorf("reading the answer: %w", err) } } return nil } // Get is Do for GET requests. func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { return c.Do(ctx, http.MethodGet, path, query, nil, out) }