Files
guard/internal/config/config.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

443 lines
12 KiB
Go

// Package config reads Guard's configuration: one YAML file,
// /etc/guard/guard.yaml (docs/configuration.md). Unknown settings are an
// error; everything not given has a default.
package config
import (
"bytes"
"errors"
"fmt"
"io"
"net/netip"
"os"
"regexp"
"strconv"
"strings"
"time"
"go.yaml.in/yaml/v3"
"guard/internal/match"
)
// DefaultPath is where the configuration is read from.
const DefaultPath = "/etc/guard/guard.yaml"
// Config is guard.yaml.
type Config struct {
Housecarl Housecarl `yaml:"housecarl"`
// NeverBan are addresses, prefixes and host names never banned.
NeverBan []string `yaml:"never_ban"`
Defaults Defaults `yaml:"defaults"`
Watch []*Watch `yaml:"watch"`
// State is where Guard keeps what must outlive a restart: repeat
// offenders, recent bans, bans waiting for Housecarl.
State string `yaml:"state"`
// Socket is the local socket 'guard status' asks.
Socket string `yaml:"socket"`
Log Log `yaml:"log"`
}
// Housecarl is how Guard reaches Housecarl.
type Housecarl struct {
// URL is Housecarl's API ("https://router.lan:8070"), or Socket its
// local socket on the firewall itself.
URL string `yaml:"url"`
Socket string `yaml:"socket"`
// TokenFile (or Token) is the API token: one for the ban lists Guard
// bans into (bans:write:LIST).
TokenFile string `yaml:"token_file"`
Token string `yaml:"token"`
// CACert trusts a private CA for Housecarl's certificate; Insecure
// skips checking it.
CACert string `yaml:"ca_cert"`
Insecure bool `yaml:"insecure"`
// List is the ban list a watch bans into unless it names another.
List string `yaml:"list"`
}
// Defaults are the watches' settings unless they say otherwise.
type Defaults struct {
// Hits matches of an address within Within ban it.
Hits int `yaml:"hits"`
Within Duration `yaml:"within"`
// Ban is how long a first ban lasts.
Ban Duration `yaml:"ban"`
Repeat Repeat `yaml:"repeat"`
}
// Repeat makes repeat offenders' bans longer: each earlier ban within
// Forget multiplies the time by Factor, up to Max.
type Repeat struct {
Factor float64 `yaml:"factor"`
Max Duration `yaml:"max"`
Forget Duration `yaml:"forget"`
}
// Log is Guard's own logging.
type Log struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// Watch is one log to watch: where its lines come from, what in them is
// a misdeed, and what a ban is.
type Watch struct {
Name string `yaml:"name"`
// Preset names built-in patterns and their usual source
// (docs/presets.md); the watch's own match lines are added.
Preset string `yaml:"preset"`
// One source: a file (or glob), the journal, or Housecarl's log of
// dropped packets (source: housecarl-drops).
File string `yaml:"file"`
Journal *Journal `yaml:"journal"`
Source string `yaml:"source"`
// Match are regular expressions with one <IP>; Ignore lines are
// skipped even when they match.
Match []string `yaml:"match"`
Ignore []string `yaml:"ignore"`
// Protocol limits housecarl-drops to a protocol (default tcp: UDP
// sources can be faked); Shadow counts shadow mode's would-be drops.
Protocol string `yaml:"protocol"`
Shadow bool `yaml:"shadow"`
// Hits and Within override the defaults.
Hits int `yaml:"hits"`
Within Duration `yaml:"within"`
Ban Ban `yaml:"ban"`
// Disabled watches are skipped.
Disabled bool `yaml:"disabled"`
}
// Journal selects journal entries: by systemd unit and/or syslog
// identifier (each a name or a list; entries of any).
type Journal struct {
Unit Strings `yaml:"unit"`
Identifier Strings `yaml:"identifier"`
}
// Ban is what a watch's ban is: into which list, of which ports, for how
// long.
type Ban struct {
List string `yaml:"list"`
// Protocol and Port limit the ban (port numbers or service names);
// without them, every port.
Protocol string `yaml:"protocol"`
Port Strings `yaml:"port"`
Time Duration `yaml:"time"`
}
// Strings is a YAML scalar or list of scalars.
type Strings []string
// UnmarshalYAML reads "x", 22, or [x, 22].
func (s *Strings) UnmarshalYAML(n *yaml.Node) error {
switch n.Kind {
case yaml.ScalarNode:
*s = Strings{n.Value}
return nil
case yaml.SequenceNode:
var out []string
for _, c := range n.Content {
if c.Kind != yaml.ScalarNode {
return fmt.Errorf("line %d: give a name, or a list of names", c.Line)
}
out = append(out, c.Value)
}
*s = out
return nil
}
return fmt.Errorf("line %d: give a name, or a list of names", n.Line)
}
// Duration is a length of time: 30s, 10m, 1h, 1d, 1w, or combined
// (1d12h).
type Duration time.Duration
var durRe = regexp.MustCompile(`^(\d+[wdhms])+$`)
// ParseDuration reads a Duration.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if !durRe.MatchString(s) {
return 0, fmt.Errorf("%q isn't a length of time: write it as 30s, 10m, 1h, 1d or 1w", s)
}
var d time.Duration
unit := map[byte]time.Duration{'w': 7 * 24 * time.Hour, 'd': 24 * time.Hour, 'h': time.Hour, 'm': time.Minute, 's': time.Second}
for _, m := range regexp.MustCompile(`(\d+)([wdhms])`).FindAllStringSubmatch(s, -1) {
n, err := strconv.Atoi(m[1])
if err != nil {
return 0, err
}
d += time.Duration(n) * unit[m[2][0]]
}
return d, nil
}
// UnmarshalYAML reads a Duration.
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
v, err := ParseDuration(n.Value)
if err != nil {
return fmt.Errorf("line %d: %v", n.Line, err)
}
*d = Duration(v)
return nil
}
// D is the time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
// String writes a Duration the short way: 1h30m, 1w.
func (d Duration) String() string { return FormatDuration(time.Duration(d)) }
// FormatDuration writes a length of time the short way.
func FormatDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
var b strings.Builder
for _, u := range []struct {
d time.Duration
s string
}{{7 * 24 * time.Hour, "w"}, {24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
if d >= u.d {
fmt.Fprintf(&b, "%d%s", d/u.d, u.s)
d %= u.d
}
}
return b.String()
}
// Load reads and checks the configuration.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(data)
}
// Parse reads and checks a configuration.
func Parse(data []byte) (*Config, error) {
c := &Config{}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("guard.yaml: %w", err)
}
c.defaults()
if err := c.check(); err != nil {
return nil, err
}
return c, nil
}
func (c *Config) defaults() {
d := &c.Defaults
if d.Hits == 0 {
d.Hits = 5
}
if d.Within == 0 {
d.Within = Duration(10 * time.Minute)
}
if d.Ban == 0 {
d.Ban = Duration(time.Hour)
}
if d.Repeat.Factor == 0 {
d.Repeat.Factor = 2
}
if d.Repeat.Max == 0 {
d.Repeat.Max = Duration(7 * 24 * time.Hour)
}
if d.Repeat.Forget == 0 {
d.Repeat.Forget = Duration(7 * 24 * time.Hour)
}
if c.State == "" {
c.State = "/var/lib/guard/state.json"
}
if c.Socket == "" {
c.Socket = "/run/guard/guard.sock"
}
if c.Log.Level == "" {
c.Log.Level = "info"
}
if c.Log.Format == "" {
c.Log.Format = "text"
}
for _, w := range c.Watch {
if w.Hits == 0 {
w.Hits = d.Hits
}
if w.Within == 0 {
w.Within = d.Within
}
if w.Ban.Time == 0 {
w.Ban.Time = d.Ban
}
if w.Ban.List == "" {
w.Ban.List = c.Housecarl.List
}
}
}
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,47}$`)
// Source names besides files and the journal.
const SourceHousecarlDrops = "housecarl-drops"
func (c *Config) check() error {
var errs []string
add := func(format string, args ...any) { errs = append(errs, fmt.Sprintf(format, args...)) }
h := c.Housecarl
switch {
case h.URL == "" && h.Socket == "":
add("housecarl: give url (https://router.lan:8070) or socket (/run/housecarl/housecarl.sock)")
case h.URL != "" && h.Socket != "":
add("housecarl: give url or socket, not both")
case h.URL != "" && !strings.HasPrefix(h.URL, "http://") && !strings.HasPrefix(h.URL, "https://"):
add("housecarl.url %q: start it with https:// (or http://)", h.URL)
}
if h.Token != "" && h.TokenFile != "" {
add("housecarl: give token_file or token, not both")
}
for _, n := range c.NeverBan {
if _, err := netip.ParsePrefix(n); err == nil {
continue
}
if _, err := netip.ParseAddr(n); err == nil {
continue
}
if !hostRe.MatchString(n) {
add("never_ban: %q isn't an address, prefix or host name", n)
}
}
d := c.Defaults
if d.Hits < 1 {
add("defaults.hits is %d; use 1 or more", d.Hits)
}
if d.Repeat.Factor < 1 {
add("defaults.repeat.factor is %g; use 1 (bans don't grow) or more", d.Repeat.Factor)
}
if lv := strings.ToLower(c.Log.Level); lv != "debug" && lv != "info" && lv != "warn" && lv != "error" {
add("log.level %q: use debug, info, warn or error", c.Log.Level)
}
if c.Log.Format != "text" && c.Log.Format != "json" {
add("log.format %q: use text or json", c.Log.Format)
}
if len(c.Watch) == 0 {
add("watch: nothing to watch; add a watch (guard config --example shows some)")
}
seen := map[string]bool{}
for i, w := range c.Watch {
what := fmt.Sprintf("watch %d", i+1)
if w.Name != "" {
what = "watch " + w.Name
}
if !nameRe.MatchString(w.Name) {
add("%s: give it a name (letters, digits, - _ .; starting with a letter)", what)
} else if seen[strings.ToLower(w.Name)] {
add("%s: the name is used twice", what)
}
seen[strings.ToLower(w.Name)] = true
if err := w.resolve(); err != nil {
add("%s: %v", what, err)
continue
}
if w.Hits < 1 {
add("%s: hits is %d; use 1 or more", what, w.Hits)
}
if w.Ban.List == "" {
add("%s: which ban list? Set housecarl.list, or ban.list", what)
}
switch strings.ToLower(w.Ban.Protocol) {
case "", "tcp", "udp":
default:
add("%s: ban.protocol %q: use tcp, udp, or leave it out", what, w.Ban.Protocol)
}
if w.Ban.Protocol != "" && len(w.Ban.Port) == 0 {
add("%s: ban.protocol without ban.port: give the port, or leave both out to ban every port", what)
}
if _, err := match.Compile(w.Match, w.Ignore); err != nil {
add("%s: %v", what, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("guard.yaml:\n %s", strings.Join(errs, "\n "))
}
return nil
}
var hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$`)
// Kind is a watch's kind of source: "file", "journal" or
// "housecarl-drops" (after resolve).
func (w *Watch) Kind() string {
switch {
case w.Source != "":
return w.Source
case w.Journal != nil:
return "journal"
}
return "file"
}
// resolve fills in a watch's preset: its source unless the watch gives
// one, its patterns before the watch's own.
func (w *Watch) resolve() error {
n := 0
for _, set := range []bool{w.File != "", w.Journal != nil, w.Source != ""} {
if set {
n++
}
}
if n > 1 {
return fmt.Errorf("give one source: file, journal or source")
}
if w.Source != "" && w.Source != SourceHousecarlDrops {
return fmt.Errorf("source %q: the only one besides file and journal is %s", w.Source, SourceHousecarlDrops)
}
if w.Preset != "" {
p, ok := Presets()[w.Preset]
if !ok {
return fmt.Errorf("there is no preset %q (guard presets lists them)", w.Preset)
}
if n == 0 {
switch {
case p.Source != "":
w.Source = p.Source
case p.Journal != nil:
j := *p.Journal
w.Journal = &j
default:
w.File = p.File
}
}
w.Match = append(append([]string{}, p.Match...), w.Match...)
w.Ignore = append(append([]string{}, p.Ignore...), w.Ignore...)
} else if n == 0 {
return fmt.Errorf("give a source: file, journal, source, or a preset")
}
if w.Journal != nil && len(w.Journal.Unit) == 0 && len(w.Journal.Identifier) == 0 {
return fmt.Errorf("journal: give a unit or an identifier (or both)")
}
if w.Source == SourceHousecarlDrops {
if len(w.Match) > 0 {
return fmt.Errorf("housecarl-drops has no patterns: every dropped packet counts")
}
switch strings.ToLower(w.Protocol) {
case "":
w.Protocol = "tcp"
case "tcp", "udp", "any":
default:
return fmt.Errorf("protocol %q: use tcp, udp or any", w.Protocol)
}
} else {
if w.Protocol != "" || w.Shadow {
return fmt.Errorf("protocol and shadow are for source housecarl-drops")
}
if len(w.Match) == 0 {
return fmt.Errorf("no match patterns: give some, or a preset")
}
}
return nil
}