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,442 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"guard/internal/match"
|
||||
)
|
||||
|
||||
// TestPresets runs each preset over its sample lines in testdata/presets:
|
||||
// what it must find, and what it must leave alone.
|
||||
func TestPresets(t *testing.T) {
|
||||
for name, p := range Presets() {
|
||||
if p.Source != "" {
|
||||
continue
|
||||
}
|
||||
m, err := match.Compile(p.Match, p.Ignore)
|
||||
if err != nil {
|
||||
t.Fatalf("preset %s: %v", name, err)
|
||||
}
|
||||
f, err := os.Open(filepath.Join("..", "..", "testdata", "presets", name+".txt"))
|
||||
if err != nil {
|
||||
t.Errorf("preset %s has no sample lines: %v", name, err)
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
n := 0
|
||||
for sc.Scan() {
|
||||
want, line, ok := strings.Cut(sc.Text(), "\t")
|
||||
if !ok || strings.HasPrefix(want, "#") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
a, matched := m.Match(line)
|
||||
switch {
|
||||
case want == "-" && matched:
|
||||
t.Errorf("%s: matched %s in %q", name, a, line)
|
||||
case want != "-" && !matched:
|
||||
t.Errorf("%s: no match in %q", name, line)
|
||||
case want != "-" && a.String() != want:
|
||||
t.Errorf("%s: %s, want %s, in %q", name, a, want, line)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
if n == 0 {
|
||||
t.Errorf("preset %s: no sample lines", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleParses(t *testing.T) {
|
||||
c, err := Parse([]byte(Example()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := c.Watch[0]
|
||||
if w.Kind() != "journal" || w.Ban.List != "web" || w.Hits != 5 || w.Within.D() != 10*time.Minute || len(w.Match) == 0 {
|
||||
t.Errorf("the ssh watch: %+v", w)
|
||||
}
|
||||
if c.Defaults.Repeat.Max.D() != 7*24*time.Hour {
|
||||
t.Errorf("repeat %+v", c.Defaults.Repeat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
base := "housecarl: {url: https://fw:8070, list: web}\n"
|
||||
cases := []struct{ yaml, want string }{
|
||||
{"", "give url"},
|
||||
{base + "watch: [{name: a, preset: nope}]", `no preset "nope"`},
|
||||
{base + "watch: [{name: a, file: /x, match: ['no address']}]", "exactly one"},
|
||||
{base + "watch: [{name: a, file: /x, journal: {unit: x}, match: ['<IP>']}]", "one source"},
|
||||
{base + "watch: [{name: a, file: /x}]", "no match patterns"},
|
||||
{base + "watch: [{name: a, source: housecarl-drops, match: ['<IP>']}]", "no patterns"},
|
||||
{base + "watch: [{name: a, preset: sshd, ban: {protocol: tcp}}]", "without ban.port"},
|
||||
{base + "watch: [{name: a, preset: sshd, within: 10 minutes}]", "isn't a length of time"},
|
||||
{base + "watch: [{name: a, preset: sshd}, {name: A, preset: sshd}]", "used twice"},
|
||||
{base + "never_ban: ['not an address!']\nwatch: [{name: a, preset: sshd}]", "never_ban"},
|
||||
{base + "bogus: 1\nwatch: [{name: a, preset: sshd}]", "bogus"},
|
||||
{"housecarl: {url: https://fw:8070}\nwatch: [{name: a, preset: sshd}]", "which ban list"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, err := Parse([]byte(c.yaml))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%q: %v, want %q", c.yaml, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetSourceAndOwnPatterns(t *testing.T) {
|
||||
c, err := Parse([]byte(`housecarl: {socket: /run/housecarl/housecarl.sock, list: router}
|
||||
watch:
|
||||
- {name: ssh, preset: sshd, file: /var/log/auth.log, match: ['custom <IP> line']}
|
||||
- {name: scans, preset: housecarl-drops}
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ssh := c.Watch[0]
|
||||
if ssh.Kind() != "file" || ssh.File != "/var/log/auth.log" || ssh.Match[len(ssh.Match)-1] != "custom <IP> line" || len(ssh.Match) < 2 {
|
||||
t.Errorf("own file and pattern beside the preset's: %+v", ssh)
|
||||
}
|
||||
if s := c.Watch[1]; s.Kind() != SourceHousecarlDrops || s.Protocol != "tcp" {
|
||||
t.Errorf("housecarl-drops: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuration(t *testing.T) {
|
||||
for in, want := range map[string]time.Duration{"30s": 30 * time.Second, "1d12h": 36 * time.Hour, "1w": 7 * 24 * time.Hour} {
|
||||
if d, err := ParseDuration(in); err != nil || d != want {
|
||||
t.Errorf("%s: %v %v", in, d, err)
|
||||
}
|
||||
if got := FormatDuration(want); got != in {
|
||||
t.Errorf("format %v: %s, want %s", want, got, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Guard's configuration (docs/configuration.md). Guard reads it when it
|
||||
# starts: systemctl restart guard after a change, and 'guard check' to
|
||||
# check it first.
|
||||
|
||||
# Housecarl, which does the banning. Give Guard a token for its ban list
|
||||
# alone; on the firewall:
|
||||
# hc banlist add web --scope hosts --host 10.0.10.57
|
||||
# hc user add guard-web --no-password --grant bans:write:web
|
||||
# hc token create web --user guard-web # as root
|
||||
housecarl:
|
||||
url: https://router.lan:8070
|
||||
# On the firewall itself, the local socket instead of url:
|
||||
# socket: /run/housecarl/housecarl.sock
|
||||
token_file: /etc/guard/token
|
||||
# ca_cert: /etc/guard/housecarl-ca.pem # a private CA
|
||||
# The ban list a watch bans into, unless it says another.
|
||||
list: web
|
||||
|
||||
# Never banned, whatever the logs say: addresses, prefixes, host names
|
||||
# (looked up every ten minutes). Housecarl itself never bans the inside
|
||||
# networks, the firewall's own addresses, or this machine.
|
||||
never_ban:
|
||||
# - 198.51.100.7
|
||||
# - 2001:db8:ffff::/48
|
||||
# - vps.example.org
|
||||
|
||||
# What every watch does unless it says otherwise: this many matches of an
|
||||
# address within this time ban it for this long. Each earlier ban of the
|
||||
# address within "forget" doubles the time ("factor"), up to "max".
|
||||
defaults:
|
||||
hits: 5
|
||||
within: 10m
|
||||
ban: 1h
|
||||
repeat: {factor: 2, max: 1w, forget: 1w}
|
||||
|
||||
watch:
|
||||
# OpenSSH, from the journal (the preset knows where).
|
||||
- name: ssh
|
||||
preset: sshd
|
||||
ban: {protocol: tcp, port: 22}
|
||||
|
||||
# A log file, with your own pattern: <IP> is where the address is.
|
||||
# - name: web-login
|
||||
# file: /var/log/nginx/access.log
|
||||
# match:
|
||||
# - '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
|
||||
# ignore:
|
||||
# - '/healthz'
|
||||
# hits: 10
|
||||
# ban: {time: 6h}
|
||||
|
||||
# Jarl's, Housecarl's or Steward's failed logins (presets jarl,
|
||||
# housecarl, steward), banned from every port of the list's scope.
|
||||
# - name: jarl
|
||||
# preset: jarl
|
||||
|
||||
# On the firewall: sources whose packets Housecarl's logged rules keep
|
||||
# dropping (TCP only: UDP sources can be faked). The token also needs
|
||||
# log:read.
|
||||
# - name: scans
|
||||
# preset: housecarl-drops
|
||||
# hits: 20
|
||||
# within: 1m
|
||||
# ban: {list: edge, time: 1d}
|
||||
|
||||
# Guard's memory of its bans (repeat offenders), and the socket 'guard
|
||||
# status' asks.
|
||||
state: /var/lib/guard/state.json
|
||||
socket: /run/guard/guard.sock
|
||||
|
||||
log: {level: info, format: text}
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"sync"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
//go:embed presets.yaml
|
||||
var presetsYAML []byte
|
||||
|
||||
// Preset is a built-in watch: patterns, and where the lines usually are.
|
||||
type Preset struct {
|
||||
Description string `yaml:"description"`
|
||||
File string `yaml:"file"`
|
||||
Journal *Journal `yaml:"journal"`
|
||||
Source string `yaml:"source"`
|
||||
Match []string `yaml:"match"`
|
||||
Ignore []string `yaml:"ignore"`
|
||||
}
|
||||
|
||||
// Presets are the built-in presets by name.
|
||||
var Presets = sync.OnceValue(func() map[string]Preset {
|
||||
out := map[string]Preset{}
|
||||
if err := yaml.Unmarshal(presetsYAML, &out); err != nil {
|
||||
panic("presets.yaml: " + err.Error())
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
//go:embed example.yaml
|
||||
var exampleYAML []byte
|
||||
|
||||
// Example is a commented configuration (guard config --example).
|
||||
func Example() string { return string(exampleYAML) }
|
||||
@@ -0,0 +1,70 @@
|
||||
# Guard's presets (docs/presets.md): patterns for common programs, and
|
||||
# where their lines usually are. A watch's own file or journal replaces
|
||||
# the preset's source; its match and ignore lines are added to these.
|
||||
#
|
||||
# Patterns are anchored where the program's format allows, and put the
|
||||
# address where the program writes it, after anything a client chooses
|
||||
# (a user name), so a chosen name can't plant another address. Lines
|
||||
# from a syslog file carry a "host prog[pid]: " head that journal
|
||||
# messages don't, hence "(?:^|: )" before a message's first word.
|
||||
|
||||
sshd:
|
||||
description: OpenSSH's failed logins, unknown users and broken handshakes
|
||||
# OpenSSH 9.8 and later (Debian 13) logs from sshd-session.
|
||||
journal: {identifier: [sshd, sshd-session]}
|
||||
match:
|
||||
- '(?:^|: )Failed (?:password|publickey|keyboard-interactive/pam|none) for .* from <IP> port \d+(?: ssh2)?$'
|
||||
- '(?:^|: )Invalid user .* from <IP> port \d+$'
|
||||
- '(?:^|: )(?:Connection (?:closed|reset)|Disconnected) (?:by|from) (?:invalid|authenticating) user .* <IP> port \d+ \[preauth\]$'
|
||||
- '(?:^|: )error: maximum authentication attempts exceeded for .* from <IP> port \d+ ssh2 \[preauth\]$'
|
||||
- '(?:^|: )Unable to negotiate with <IP> port \d+: no matching (?:key exchange method|host key type|cipher|MAC) found'
|
||||
- '(?:^|: )banner exchange: Connection from <IP> port \d+: invalid format$'
|
||||
|
||||
jarl:
|
||||
description: failed logins and refused API tokens of Jarl (the BIND manager)
|
||||
journal: {unit: jarl.service}
|
||||
match:
|
||||
- 'msg="(?:login failed|token refused)" ip=<IP> '
|
||||
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
|
||||
|
||||
housecarl:
|
||||
description: failed logins and refused API tokens of Housecarl (the firewall manager)
|
||||
journal: {unit: housecarl.service}
|
||||
match:
|
||||
- 'msg="(?:login failed|token refused)" ip=<IP> '
|
||||
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
|
||||
|
||||
steward:
|
||||
description: failed logins and refused API tokens of Steward (the DHCP server)
|
||||
journal: {unit: steward.service}
|
||||
match:
|
||||
- 'msg="(?:login failed|token refused)" ip=<IP> '
|
||||
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
|
||||
|
||||
bind-xfr:
|
||||
description: zone transfers BIND refused (over TCP, so the address is real)
|
||||
journal: {identifier: named}
|
||||
match:
|
||||
- 'client @0x[0-9a-f]+ <IP>#\d+ .*: zone transfer .* denied$'
|
||||
|
||||
postfix-sasl:
|
||||
description: Postfix's failed SMTP logins (SASL)
|
||||
journal: {identifier: [postfix/smtpd, postfix/submission/smtpd, postfix/smtps/smtpd]}
|
||||
match:
|
||||
- 'warning: [-._0-9A-Za-z]+\[<IP>\]: SASL [-A-Z0-9]+ authentication failed'
|
||||
|
||||
dovecot:
|
||||
description: Dovecot's failed IMAP and POP3 logins
|
||||
journal: {identifier: dovecot}
|
||||
match:
|
||||
- '(?:imap|pop3|submission|managesieve)-login: (?:Disconnected|Aborted login).*\(auth failed, \d+ attempts? in \d+ secs\): user=<.*>, .*rip=<IP>, lip='
|
||||
|
||||
nginx-auth:
|
||||
description: nginx's failed HTTP basic authentication (error.log)
|
||||
file: /var/log/nginx/error.log
|
||||
match:
|
||||
- '\buser "[^"]*"(?: was not found in "[^"]*"|: password mismatch), client: <IP>, server: '
|
||||
|
||||
housecarl-drops:
|
||||
description: packets Housecarl's rules dropped and logged (on the firewall; TCP only by default)
|
||||
source: housecarl-drops
|
||||
Reference in new issue
Block a user