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>
This commit is contained in:
commit
d06bf8d531
47 files changed
+4614
No files matched your search
@@ -0,0 +1,253 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user