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>
443 lines
12 KiB
Go
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
|
|
}
|