// 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" "strconv" "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 // Changeset stages configuration changes in that changeset instead // of applying them (X-Housecarl-Changeset). Changeset int64 // BeforeStage, if set, runs once before the first change is staged, // and can stop it (the CLI checks HOUSECARL_CHANGESET's changeset). BeforeStage func() error staged bool } // Staged reports whether a request is a configuration change that a // changeset takes: objects and settings, and the changes that can't be // staged (undo, import, takeover), which the server then refuses rather // than apply them. Changesets' own calls and dynamic set entries aren't. func Staged(method, path string) bool { if method == http.MethodGet || strings.Contains(path, "/elements") { return false } for _, p := range []string{"/zones", "/sets", "/services", "/rules", "/forwards", "/snats", "/redirects", "/settings", "/history/", "/import/", "/takeover"} { if strings.HasPrefix(path, p) { return true } } return false } // 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") } stage := c.Changeset != 0 && Staged(method, path) if stage && !c.staged && c.BeforeStage != nil { if err := c.BeforeStage(); err != nil { return err } } 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) } if stage { req.Header.Set("X-Housecarl-Changeset", strconv.FormatInt(c.Changeset, 10)) c.staged = true } 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) }