// 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 ; 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 }