Tables name a journal source by its first two units or identifiers; guard test shows 20 matching lines and addresses (--all for all), no /32; Housecarl connection errors without Go's request URL, said once by guard check; status tells "not running" from "no permission"; bans says when there are none; "1 ban lifted". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
7.2 KiB
Go
281 lines
7.2 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
|
|
socket 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, socket: o.Socket, 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
|
|
}
|
|
|
|
// UnreachableError is a request Housecarl never answered: no
|
|
// connection, a TLS failure, a timeout.
|
|
type UnreachableError struct {
|
|
Where string
|
|
Err error
|
|
}
|
|
|
|
func (e *UnreachableError) Error() string {
|
|
err := e.Err
|
|
var ue *url.Error
|
|
if errors.As(err, &ue) {
|
|
err = ue.Err // without the method and the URL with its query
|
|
}
|
|
return "Housecarl at " + e.Where + " doesn't answer: " + err.Error()
|
|
}
|
|
|
|
func (e *UnreachableError) Unwrap() error { return e.Err }
|
|
|
|
// where is where the client reaches Housecarl, for messages.
|
|
func (c *Client) where() string {
|
|
if c.socket != "" {
|
|
return c.socket
|
|
}
|
|
return c.base
|
|
}
|
|
|
|
// 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 &UnreachableError{Where: c.where(), Err: 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
|
|
}
|