Files
guard/internal/housecarl/client.go
T
abrown38andClaude Opus 5.5 d06bf8d531 Guard: watch logs, ban misbehaving addresses in Housecarl
Files and the journal, patterns with one <IP>, bounties within a window,
bans in a Housecarl ban list with repeat offenders' bans growing; presets
for sshd, jarl, housecarl, steward, bind-xfr, postfix-sasl, dovecot,
nginx-auth and housecarl-drops; guard run/check/test/status/bans/ban/unban;
a Debian package with a sandboxed unit; an end-to-end test with Housecarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00

254 lines
6.5 KiB
Go

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