// Package housecarl is Guard's client for Housecarl's API: ban lists and // their bans, and the log of dropped packets (Housecarl's docs/bans.md // and docs/api.md). package housecarl import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "time" "guard/internal/version" ) // Prefix starts every API path. const Prefix = "/api/v1" // Client talks to one Housecarl. type Client struct { base string token string http *http.Client } // Options say how to reach Housecarl. type Options struct { URL, Socket string Token string CACert string Insecure bool Timeout time.Duration } // New makes a client. func New(o Options) (*Client, error) { timeout := o.Timeout if timeout == 0 { timeout = 15 * time.Second } tr := http.DefaultTransport.(*http.Transport).Clone() base := strings.TrimRight(o.URL, "/") if o.Socket != "" { sock := o.Socket tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", sock) } base = "http://housecarl" } if o.CACert != "" || o.Insecure { cfg := &tls.Config{InsecureSkipVerify: o.Insecure} if o.CACert != "" { pem, err := os.ReadFile(o.CACert) if err != nil { return nil, err } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("%s holds no PEM certificate", o.CACert) } cfg.RootCAs = pool } tr.TLSClientConfig = cfg } return &Client{base: base, token: strings.TrimSpace(o.Token), http: &http.Client{Transport: tr, Timeout: timeout}}, nil } // Error is an error answer from Housecarl. type Error struct { Status int Code string Message string } func (e *Error) Error() string { if e.Message == "" { return fmt.Sprintf("Housecarl answered %d", e.Status) } return e.Message } // Refused reports whether Housecarl refused a request for good: asking // again won't help (a bad request, no permission, an address never // banned). Anything else (no answer, a server error) is worth retrying. func Refused(err error) bool { var he *Error return errors.As(err, &he) && he.Status >= 400 && he.Status < 500 && he.Status != http.StatusTooManyRequests } func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error { var rd io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } rd = bytes.NewReader(b) } u := c.base + Prefix + path if len(query) > 0 { u += "?" + query.Encode() } req, err := http.NewRequestWithContext(ctx, method, u, rd) if err != nil { return err } req.Header.Set("User-Agent", "guard/"+version.Short()) if body != nil { req.Header.Set("Content-Type", "application/json") } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) if resp.StatusCode >= 300 { var eb struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } json.Unmarshal(data, &eb) return &Error{Status: resp.StatusCode, Code: eb.Error.Code, Message: eb.Error.Message} } if out != nil && len(data) > 0 { return json.Unmarshal(data, out) } return nil } // BanList is a Housecarl ban list (the fields Guard uses). type BanList struct { ID int64 `json:"id"` Name string `json:"name"` Scope string `json:"scope"` DefaultTime int `json:"default_time"` MaxTime int `json:"max_time"` GroupV6 int `json:"group_v6"` Disabled bool `json:"disabled"` } // V6Group is the IPv6 prefix length the list bans by. func (b *BanList) V6Group() int { if b.GroupV6 > 0 { return b.GroupV6 } return 64 } // Ban is an active ban. type Ban struct { ID int64 `json:"id"` List string `json:"list"` Address string `json:"address"` Protocol string `json:"protocol"` Port int `json:"port"` Expires *time.Time `json:"expires"` Timeout int `json:"timeout"` Created time.Time `json:"created"` Who string `json:"who"` Source string `json:"source"` Reason string `json:"reason"` Existing bool `json:"existing"` } // BanInput asks for a ban. type BanInput struct { Address string `json:"address"` Protocol string `json:"protocol,omitempty"` Ports string `json:"ports,omitempty"` Time int `json:"time,omitempty"` Source string `json:"source,omitempty"` Reason string `json:"reason,omitempty"` } // BanResult is what a ban made. type BanResult struct { Bans []Ban `json:"bans"` Closed int `json:"closed"` } // LogEntry is a packet in Housecarl's log. type LogEntry struct { Time time.Time `json:"time"` Prefix string `json:"prefix"` Protocol string `json:"protocol"` Source string `json:"source"` Dest string `json:"destination"` SrcPort int `json:"source_port"` DestPort int `json:"destination_port"` Verdict string `json:"verdict"` Shadow bool `json:"shadow"` } func listPath(list string) string { return "/banlists/" + url.PathEscape(list) } // BanList reads a ban list. func (c *Client) BanList(ctx context.Context, list string) (*BanList, error) { var b BanList if err := c.do(ctx, "GET", listPath(list), nil, nil, &b); err != nil { return nil, err } return &b, nil } // Ban bans an address. func (c *Client) Ban(ctx context.Context, list string, in BanInput) (*BanResult, error) { var out BanResult if err := c.do(ctx, "POST", listPath(list)+"/bans", nil, in, &out); err != nil { return nil, err } return &out, nil } // Unban lifts every ban of an address in a list. func (c *Client) Unban(ctx context.Context, list, addr string) (int, error) { var out map[string]int err := c.do(ctx, "DELETE", listPath(list)+"/bans", url.Values{"address": {addr}}, nil, &out) return out["removed"], err } // Bans lists a list's active bans ("" for every list the token may read). func (c *Client) Bans(ctx context.Context, list string) ([]Ban, error) { path := "/bans" if list != "" { path = listPath(list) + "/bans" } var out []Ban err := c.do(ctx, "GET", path, nil, nil, &out) return out, err } // Log reads Housecarl's log of packets after a time, newest first. func (c *Client) Log(ctx context.Context, after time.Time) ([]LogEntry, error) { var out []LogEntry err := c.do(ctx, "GET", "/log", url.Values{"after": {after.UTC().Format(time.RFC3339Nano)}, "limit": {"2000"}}, nil, &out) return out, err }