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,120 @@
|
||||
// Package bounty keeps each address's bounty in a watch: its matches
|
||||
// within the watch's window. An address whose bounty reaches the watch's
|
||||
// hits is banned, and starts again from nothing.
|
||||
package bounty
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxTracked is the most addresses a watch keeps a bounty for; past it,
|
||||
// the ones seen longest ago are forgotten.
|
||||
const MaxTracked = 100000
|
||||
|
||||
// Tracker counts one watch's matches per address (or IPv6 prefix).
|
||||
type Tracker struct {
|
||||
Hits int
|
||||
Within time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
seen map[netip.Prefix][]time.Time
|
||||
}
|
||||
|
||||
// New makes a Tracker.
|
||||
func New(hits int, within time.Duration) *Tracker {
|
||||
return &Tracker{Hits: hits, Within: within, seen: map[netip.Prefix][]time.Time{}}
|
||||
}
|
||||
|
||||
// Add counts a match of p at a time. It returns p's bounty, and whether
|
||||
// it reached the hits (p's bounty is then cleared).
|
||||
func (t *Tracker) Add(p netip.Prefix, at time.Time) (int, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
ts := keep(t.seen[p], at.Add(-t.Within))
|
||||
ts = append(ts, at)
|
||||
if len(ts) >= t.Hits {
|
||||
delete(t.seen, p)
|
||||
return len(ts), true
|
||||
}
|
||||
t.seen[p] = ts
|
||||
if len(t.seen) > MaxTracked {
|
||||
t.prune(at)
|
||||
}
|
||||
return len(ts), false
|
||||
}
|
||||
|
||||
// keep drops the times before from (times are in order).
|
||||
func keep(ts []time.Time, from time.Time) []time.Time {
|
||||
i := 0
|
||||
for i < len(ts) && ts[i].Before(from) {
|
||||
i++
|
||||
}
|
||||
return ts[i:]
|
||||
}
|
||||
|
||||
// prune forgets what is out of the window, then, if still too many, the
|
||||
// addresses seen longest ago.
|
||||
func (t *Tracker) prune(now time.Time) {
|
||||
for p, ts := range t.seen {
|
||||
if ts = keep(ts, now.Add(-t.Within)); len(ts) == 0 {
|
||||
delete(t.seen, p)
|
||||
} else {
|
||||
t.seen[p] = ts
|
||||
}
|
||||
}
|
||||
if len(t.seen) <= MaxTracked {
|
||||
return
|
||||
}
|
||||
type last struct {
|
||||
p netip.Prefix
|
||||
t time.Time
|
||||
}
|
||||
all := make([]last, 0, len(t.seen))
|
||||
for p, ts := range t.seen {
|
||||
all = append(all, last{p, ts[len(ts)-1]})
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) })
|
||||
for _, x := range all[:len(all)-MaxTracked*9/10] {
|
||||
delete(t.seen, x.p)
|
||||
}
|
||||
}
|
||||
|
||||
// Bounty is an address's bounty.
|
||||
type Bounty struct {
|
||||
Address string `json:"address"`
|
||||
Bounty int `json:"bounty"`
|
||||
Last time.Time `json:"last"`
|
||||
}
|
||||
|
||||
// Top returns the n highest bounties now (within the window), highest
|
||||
// first.
|
||||
func (t *Tracker) Top(n int, now time.Time) []Bounty {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
var out []Bounty
|
||||
for p, ts := range t.seen {
|
||||
if ts = keep(ts, now.Add(-t.Within)); len(ts) > 0 {
|
||||
out = append(out, Bounty{Address: address(p), Bounty: len(ts), Last: ts[len(ts)-1]})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Bounty != out[j].Bounty {
|
||||
return out[i].Bounty > out[j].Bounty
|
||||
}
|
||||
return out[i].Last.After(out[j].Last)
|
||||
})
|
||||
if len(out) > n {
|
||||
out = out[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func address(p netip.Prefix) string {
|
||||
if p.IsSingleIP() {
|
||||
return p.Addr().String()
|
||||
}
|
||||
return p.String()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package bounty
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTracker(t *testing.T) {
|
||||
tr := New(3, time.Minute)
|
||||
p := netip.MustParsePrefix("203.0.113.9/32")
|
||||
t0 := time.Now()
|
||||
if n, r := tr.Add(p, t0); n != 1 || r {
|
||||
t.Errorf("first: %d %v", n, r)
|
||||
}
|
||||
// Two minutes later the first is out of the window.
|
||||
tr.Add(p, t0.Add(2*time.Minute))
|
||||
if n, r := tr.Add(p, t0.Add(2*time.Minute+time.Second)); n != 2 || r {
|
||||
t.Errorf("window: %d %v", n, r)
|
||||
}
|
||||
if top := tr.Top(5, t0.Add(2*time.Minute+time.Second)); len(top) != 1 || top[0].Bounty != 2 || top[0].Address != "203.0.113.9" {
|
||||
t.Errorf("top %+v", top)
|
||||
}
|
||||
if n, r := tr.Add(p, t0.Add(2*time.Minute+2*time.Second)); n != 3 || !r {
|
||||
t.Errorf("reached: %d %v", n, r)
|
||||
}
|
||||
if top := tr.Top(5, t0.Add(3*time.Minute)); len(top) != 0 {
|
||||
t.Errorf("after the ban the bounty starts again: %+v", top)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,541 @@
|
||||
// Package daemon is Guard at work: each watch's source feeds its lines
|
||||
// to its patterns; an address that matches often enough is banned in
|
||||
// Housecarl; bans Housecarl can't take now are sent again later.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"guard/internal/bounty"
|
||||
"guard/internal/config"
|
||||
"guard/internal/housecarl"
|
||||
"guard/internal/match"
|
||||
"guard/internal/source"
|
||||
"guard/internal/state"
|
||||
)
|
||||
|
||||
// Banner is what bans go to: Housecarl (a fake in tests).
|
||||
type Banner interface {
|
||||
Ban(ctx context.Context, list string, in housecarl.BanInput) (*housecarl.BanResult, error)
|
||||
BanList(ctx context.Context, list string) (*housecarl.BanList, error)
|
||||
}
|
||||
|
||||
// Daemon is a running Guard.
|
||||
type Daemon struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
hc Banner
|
||||
state *state.State
|
||||
never *NeverBan
|
||||
watches []*Watch
|
||||
started time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
groups map[string]int // ban list → IPv6 grouping
|
||||
// pending are the bans Housecarl hasn't taken yet, by list and group.
|
||||
pending map[string]state.Ban
|
||||
wake chan struct{}
|
||||
// refused remembers addresses Housecarl refused, so each is logged
|
||||
// once an hour.
|
||||
refused map[string]time.Time
|
||||
|
||||
// Now and RetryEvery can be changed by tests.
|
||||
Now func() time.Time
|
||||
RetryEvery time.Duration
|
||||
}
|
||||
|
||||
// Watch is one watch at work.
|
||||
type Watch struct {
|
||||
Cfg *config.Watch
|
||||
Matcher *match.Matcher
|
||||
Source source.Source
|
||||
Tracker *bounty.Tracker
|
||||
|
||||
lines, matches, bans atomic.Uint64
|
||||
}
|
||||
|
||||
// New makes a Daemon. hc may be a fake; sources come from the watches'
|
||||
// configuration unless a test sets them (Watch.Source).
|
||||
func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State, log *slog.Logger) (*Daemon, error) {
|
||||
d := &Daemon{cfg: cfg, log: log, hc: hc, state: st, never: NewNeverBan(cfg.NeverBan, log), groups: map[string]int{},
|
||||
pending: map[string]state.Ban{}, wake: make(chan struct{}, 1), refused: map[string]time.Time{},
|
||||
Now: time.Now, RetryEvery: 30 * time.Second}
|
||||
for _, b := range st.Pending {
|
||||
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
|
||||
}
|
||||
for _, w := range cfg.Watch {
|
||||
if w.Disabled {
|
||||
continue
|
||||
}
|
||||
m, err := match.Compile(w.Match, w.Ignore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watch %s: %v", w.Name, err)
|
||||
}
|
||||
wt := &Watch{Cfg: w, Matcher: m, Tracker: bounty.New(w.Hits, w.Within.D())}
|
||||
switch w.Kind() {
|
||||
case "file":
|
||||
wt.Source = &source.File{Pattern: w.File, Log: log.With("watch", w.Name)}
|
||||
case "journal":
|
||||
wt.Source = &source.Journal{Units: w.Journal.Unit, Identifiers: w.Journal.Identifier, Log: log.With("watch", w.Name)}
|
||||
case config.SourceHousecarlDrops:
|
||||
wt.Source = &source.HousecarlDrops{Client: logs, Protocol: w.Protocol, Shadow: w.Shadow, Log: log.With("watch", w.Name)}
|
||||
}
|
||||
d.watches = append(d.watches, wt)
|
||||
}
|
||||
if len(d.watches) == 0 {
|
||||
return nil, errors.New("every watch is disabled: nothing to do")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Watches are the watches at work (tests set their sources).
|
||||
func (d *Daemon) Watches() []*Watch { return d.watches }
|
||||
|
||||
// Run watches until ctx ends.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
d.started = d.Now()
|
||||
d.loadGroups(ctx)
|
||||
go d.never.Run(ctx)
|
||||
var wg sync.WaitGroup
|
||||
for _, w := range d.watches {
|
||||
lines := make(chan source.Line, 256)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer close(lines)
|
||||
for {
|
||||
err := w.Source.Run(ctx, lines)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d.log.Error("the watch's source stopped; starting it again in 10 seconds", "watch", w.Cfg.Name, "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for l := range lines {
|
||||
d.Line(ctx, w, l)
|
||||
}
|
||||
}()
|
||||
d.log.Info("watching", "watch", w.Cfg.Name, "source", Describe(w.Cfg), "patterns", len(w.Cfg.Match), "hits", w.Cfg.Hits, "within", w.Cfg.Within.String(), "list", w.Cfg.Ban.List)
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d.sender(ctx)
|
||||
}()
|
||||
if d.cfg.Socket != "" {
|
||||
if err := d.serveStatus(ctx); err != nil {
|
||||
d.log.Warn("no status socket (guard status won't work)", "socket", d.cfg.Socket, "err", err)
|
||||
}
|
||||
}
|
||||
<-ctx.Done()
|
||||
wg.Wait()
|
||||
d.savePending()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Describe says where a watch's lines come from.
|
||||
func Describe(w *config.Watch) string {
|
||||
switch w.Kind() {
|
||||
case "journal":
|
||||
var parts []string
|
||||
if len(w.Journal.Unit) > 0 {
|
||||
parts = append(parts, "unit "+strings.Join(w.Journal.Unit, ", "))
|
||||
}
|
||||
if len(w.Journal.Identifier) > 0 {
|
||||
parts = append(parts, "identifier "+strings.Join(w.Journal.Identifier, ", "))
|
||||
}
|
||||
return "journal (" + strings.Join(parts, "; ") + ")"
|
||||
case config.SourceHousecarlDrops:
|
||||
return "Housecarl's dropped packets (" + w.Protocol + ")"
|
||||
}
|
||||
return "file " + w.File
|
||||
}
|
||||
|
||||
// loadGroups asks Housecarl how each ban list groups IPv6 addresses, so
|
||||
// bounties are counted as the list bans. A list that can't be read now
|
||||
// counts by /64, its default.
|
||||
func (d *Daemon) loadGroups(ctx context.Context) {
|
||||
for _, w := range d.watches {
|
||||
list := w.Cfg.Ban.List
|
||||
d.mu.Lock()
|
||||
_, done := d.groups[list]
|
||||
d.mu.Unlock()
|
||||
if done {
|
||||
continue
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
bl, err := d.hc.BanList(c, list)
|
||||
cancel()
|
||||
group := 64
|
||||
switch {
|
||||
case err != nil:
|
||||
d.log.Warn("can't read the ban list from Housecarl (yet); counting IPv6 addresses by /64", "list", list, "err", err)
|
||||
case bl.Disabled:
|
||||
d.log.Warn("the ban list is disabled in Housecarl: its bans won't be in force", "list", list)
|
||||
group = bl.V6Group()
|
||||
default:
|
||||
group = bl.V6Group()
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.groups[list] = group
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) group(list string) int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if g, ok := d.groups[list]; ok {
|
||||
return g
|
||||
}
|
||||
return 64
|
||||
}
|
||||
|
||||
// Line handles one line of a watch.
|
||||
func (d *Daemon) Line(ctx context.Context, w *Watch, l source.Line) {
|
||||
w.lines.Add(1)
|
||||
addr := l.Addr
|
||||
if !addr.IsValid() {
|
||||
var ok bool
|
||||
if addr, ok = w.Matcher.Match(l.Text); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.matches.Add(1)
|
||||
if why := d.never.Why(addr); why != "" {
|
||||
d.log.Debug("match, but never banned", "watch", w.Cfg.Name, "address", addr, "why", why)
|
||||
return
|
||||
}
|
||||
g := match.Group(addr, d.group(w.Cfg.Ban.List))
|
||||
n, reached := w.Tracker.Add(g, l.Time)
|
||||
d.log.Debug("match", "watch", w.Cfg.Name, "address", addr, "bounty", n, "hits", w.Cfg.Hits, "line", clip(l.Text, 300))
|
||||
if !reached {
|
||||
return
|
||||
}
|
||||
w.bans.Add(1)
|
||||
d.ban(w, addr, g, l.Text)
|
||||
}
|
||||
|
||||
// BanTime is how long a group's next ban lasts: the watch's time,
|
||||
// times the repeat factor for each earlier ban within forget, up to the
|
||||
// most.
|
||||
func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
|
||||
r := d.cfg.Defaults.Repeat
|
||||
base := w.Ban.Time.D()
|
||||
n := d.state.Repeats(group, d.Now().Add(-r.Forget.D()))
|
||||
t := time.Duration(float64(base) * math.Pow(r.Factor, float64(n)))
|
||||
if t > r.Max.D() || t < 0 {
|
||||
t = r.Max.D()
|
||||
}
|
||||
if t < base {
|
||||
t = base
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (d *Daemon) ban(w *Watch, addr netip.Addr, g netip.Prefix, line string) {
|
||||
group := g.String()
|
||||
t := d.BanTime(w.Cfg, group)
|
||||
b := state.Ban{Time: d.Now(), Watch: w.Cfg.Name, List: w.Cfg.Ban.List, Address: addr.String(), Group: group,
|
||||
Protocol: strings.ToLower(w.Cfg.Ban.Protocol), Ports: strings.Join(w.Cfg.Ban.Port, ","), Seconds: int(t / time.Second), Reason: clip(line, 300)}
|
||||
d.log.Info("bounty reached; banning", "watch", w.Cfg.Name, "address", addr, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(t))
|
||||
d.mu.Lock()
|
||||
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
|
||||
d.mu.Unlock()
|
||||
select {
|
||||
case d.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func portsText(b state.Ban) string {
|
||||
if b.Ports == "" {
|
||||
return "all"
|
||||
}
|
||||
if b.Protocol == "" {
|
||||
return b.Ports
|
||||
}
|
||||
return b.Protocol + "/" + b.Ports
|
||||
}
|
||||
|
||||
// sender sends the pending bans to Housecarl: at once, and again every
|
||||
// RetryEvery while Housecarl can't be reached.
|
||||
func (d *Daemon) sender(ctx context.Context) {
|
||||
t := time.NewTicker(d.RetryEvery)
|
||||
defer t.Stop()
|
||||
failing := false
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-d.wake:
|
||||
case <-t.C:
|
||||
}
|
||||
d.mu.Lock()
|
||||
var todo []state.Ban
|
||||
for _, b := range d.pending {
|
||||
todo = append(todo, b)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
sort.Slice(todo, func(i, j int) bool { return todo[i].Time.Before(todo[j].Time) })
|
||||
for _, b := range todo {
|
||||
err := d.send(ctx, b)
|
||||
if err != nil && !housecarl.Refused(err) {
|
||||
if !failing {
|
||||
d.log.Warn("can't reach Housecarl; the bans wait and are sent again", "err", err, "waiting", len(todo))
|
||||
failing = true
|
||||
}
|
||||
break
|
||||
}
|
||||
if failing {
|
||||
d.log.Info("Housecarl is back; sending the bans that waited")
|
||||
failing = false
|
||||
}
|
||||
d.mu.Lock()
|
||||
if cur, ok := d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports]; ok && cur.Time.Equal(b.Time) {
|
||||
delete(d.pending, b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
d.savePending()
|
||||
}
|
||||
}
|
||||
|
||||
// send makes one ban. A ban Housecarl refuses (never banned, no
|
||||
// permission) isn't sent again.
|
||||
func (d *Daemon) send(ctx context.Context, b state.Ban) error {
|
||||
c, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
// What waited is sent for the time it has left.
|
||||
secs := b.Seconds - int(d.Now().Sub(b.Time)/time.Second)
|
||||
if secs <= 0 {
|
||||
return nil
|
||||
}
|
||||
res, err := d.hc.Ban(c, b.List, housecarl.BanInput{Address: b.Address, Protocol: b.Protocol, Ports: b.Ports, Time: secs,
|
||||
Source: "guard/" + b.Watch, Reason: b.Reason})
|
||||
if err != nil {
|
||||
if housecarl.Refused(err) {
|
||||
b.Result = err.Error()
|
||||
d.state.Failed(b)
|
||||
d.mu.Lock()
|
||||
last, seen := d.refused[b.Group]
|
||||
d.refused[b.Group] = d.Now()
|
||||
d.mu.Unlock()
|
||||
if !seen || d.Now().Sub(last) > time.Hour {
|
||||
d.log.Warn("Housecarl refused the ban", "watch", b.Watch, "address", b.Address, "list", b.List, "err", err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
b.Result = "banned"
|
||||
for _, x := range res.Bans {
|
||||
if x.Existing {
|
||||
b.Result = "renewed"
|
||||
}
|
||||
}
|
||||
d.state.Banned(b)
|
||||
d.log.Info("banned", "watch", b.Watch, "address", b.Address, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(time.Duration(secs)*time.Second),
|
||||
"result", b.Result, "connections_closed", res.Closed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) savePending() {
|
||||
d.mu.Lock()
|
||||
var p []state.Ban
|
||||
for _, b := range d.pending {
|
||||
p = append(p, b)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.state.SetPending(p)
|
||||
d.state.Forget(d.Now().Add(-d.cfg.Defaults.Repeat.Forget.D()))
|
||||
if err := d.state.Save(); err != nil {
|
||||
d.log.Warn("saving the state failed", "file", d.cfg.State, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- status
|
||||
|
||||
// Status is what guard status shows.
|
||||
type Status struct {
|
||||
Started time.Time `json:"started"`
|
||||
Watches []WatchStatus `json:"watches"`
|
||||
Recent []state.Ban `json:"recent"`
|
||||
Pending []state.Ban `json:"pending"`
|
||||
}
|
||||
|
||||
// WatchStatus is a watch's.
|
||||
type WatchStatus struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
List string `json:"list"`
|
||||
Hits int `json:"hits"`
|
||||
Within string `json:"within"`
|
||||
Lines uint64 `json:"lines"`
|
||||
Matches uint64 `json:"matches"`
|
||||
Bans uint64 `json:"bans"`
|
||||
Top []bounty.Bounty `json:"top"`
|
||||
}
|
||||
|
||||
// Status reports the watches, their highest bounties and the recent bans.
|
||||
func (d *Daemon) Status() Status {
|
||||
s := Status{Started: d.started}
|
||||
for _, w := range d.watches {
|
||||
s.Watches = append(s.Watches, WatchStatus{Name: w.Cfg.Name, Source: Describe(w.Cfg), List: w.Cfg.Ban.List, Hits: w.Cfg.Hits,
|
||||
Within: w.Cfg.Within.String(), Lines: w.lines.Load(), Matches: w.matches.Load(), Bans: w.bans.Load(), Top: w.Tracker.Top(10, d.Now())})
|
||||
}
|
||||
s.Recent, s.Pending = d.state.Snapshot()
|
||||
if len(s.Recent) > 50 {
|
||||
s.Recent = s.Recent[:50]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// serveStatus answers GET /status on the local socket (root and the
|
||||
// guard user only: the socket is 0660 in /run/guard).
|
||||
func (d *Daemon) serveStatus(ctx context.Context) error {
|
||||
path := d.cfg.Socket
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(path)
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Chmod(path, 0o660)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(d.Status())
|
||||
})
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
go srv.Serve(ln)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
srv.Close()
|
||||
os.Remove(path)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- never banned
|
||||
|
||||
// NeverBan holds the addresses never banned: prefixes, and host names
|
||||
// looked up every ten minutes.
|
||||
type NeverBan struct {
|
||||
log *slog.Logger
|
||||
prefixes []netip.Prefix
|
||||
names []string
|
||||
mu sync.RWMutex
|
||||
resolved map[string][]netip.Addr
|
||||
// Lookup resolves names (tests).
|
||||
Lookup func(ctx context.Context, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// NewNeverBan reads never_ban (checked by config).
|
||||
func NewNeverBan(list []string, log *slog.Logger) *NeverBan {
|
||||
n := &NeverBan{log: log, resolved: map[string][]netip.Addr{},
|
||||
Lookup: func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
}}
|
||||
for _, e := range list {
|
||||
if p, err := netip.ParsePrefix(e); err == nil {
|
||||
n.prefixes = append(n.prefixes, p.Masked())
|
||||
} else if a, err := netip.ParseAddr(e); err == nil {
|
||||
n.prefixes = append(n.prefixes, netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()))
|
||||
} else {
|
||||
n.names = append(n.names, e)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Run looks the names up now and every ten minutes.
|
||||
func (n *NeverBan) Run(ctx context.Context) {
|
||||
if len(n.names) == 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
n.resolve(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Minute):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NeverBan) resolve(ctx context.Context) {
|
||||
for _, h := range n.names {
|
||||
c, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
addrs, err := n.Lookup(c, h)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// Keep what it was: a name that can't be looked up now is still
|
||||
// never banned as it was.
|
||||
if n.log != nil {
|
||||
n.log.Warn("can't look up a never_ban name; keeping its last addresses", "name", h, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for i := range addrs {
|
||||
addrs[i] = addrs[i].Unmap()
|
||||
}
|
||||
n.mu.Lock()
|
||||
n.resolved[h] = addrs
|
||||
n.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Why says why an address is never banned ("" when it may be).
|
||||
func (n *NeverBan) Why(a netip.Addr) string {
|
||||
a = a.Unmap()
|
||||
switch {
|
||||
case a.IsLoopback(), a.IsUnspecified(), a.IsMulticast(), a.IsLinkLocalUnicast():
|
||||
return "loopback, link-local, multicast or unspecified"
|
||||
}
|
||||
for _, p := range n.prefixes {
|
||||
if p.Contains(a) {
|
||||
return "in never_ban " + p.String()
|
||||
}
|
||||
}
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
for h, addrs := range n.resolved {
|
||||
for _, x := range addrs {
|
||||
if x == a {
|
||||
return "never_ban " + h
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"guard/internal/config"
|
||||
"guard/internal/housecarl"
|
||||
"guard/internal/source"
|
||||
"guard/internal/state"
|
||||
)
|
||||
|
||||
type fakeHC struct {
|
||||
mu sync.Mutex
|
||||
bans []housecarl.BanInput
|
||||
fail error
|
||||
}
|
||||
|
||||
func (f *fakeHC) Ban(_ context.Context, list string, in housecarl.BanInput) (*housecarl.BanResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.fail != nil {
|
||||
return nil, f.fail
|
||||
}
|
||||
f.bans = append(f.bans, in)
|
||||
return &housecarl.BanResult{Bans: []housecarl.Ban{{Address: in.Address, List: list}}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeHC) BanList(_ context.Context, list string) (*housecarl.BanList, error) {
|
||||
return &housecarl.BanList{Name: list, GroupV6: 64}, nil
|
||||
}
|
||||
|
||||
func (f *fakeHC) setFail(err error) {
|
||||
f.mu.Lock()
|
||||
f.fail = err
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeHC) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.bans)
|
||||
}
|
||||
|
||||
func (f *fakeHC) last() housecarl.BanInput {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.bans[len(f.bans)-1]
|
||||
}
|
||||
|
||||
type chanSource struct{ ch chan source.Line }
|
||||
|
||||
func (c *chanSource) Run(ctx context.Context, out chan<- source.Line) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case l := <-c.ch:
|
||||
out <- l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 200; i++ {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waited in vain: %s", what)
|
||||
}
|
||||
|
||||
// TestDaemon: hits within the window ban, by the watch's ports and time;
|
||||
// a repeat offender's ban is longer; IPv6 counts by /64; never_ban is
|
||||
// never banned; bans wait while Housecarl can't be reached, and a
|
||||
// refused one isn't sent again.
|
||||
func TestDaemon(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg, err := config.Parse([]byte(`housecarl: {url: https://fw:8070, list: web}
|
||||
never_ban: [198.51.100.0/24]
|
||||
defaults: {hits: 3, within: 10m, ban: 1h, repeat: {factor: 2, max: 3h}}
|
||||
watch:
|
||||
- {name: ssh, preset: sshd, file: /var/log/auth.log, ban: {protocol: tcp, port: 22}}
|
||||
state: ` + filepath.Join(dir, "state.json") + `
|
||||
socket: ` + filepath.Join(dir, "guard.sock") + `
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ := state.Load(cfg.State)
|
||||
hc := &fakeHC{}
|
||||
d, err := New(cfg, hc, nil, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.RetryEvery = 50 * time.Millisecond
|
||||
src := &chanSource{ch: make(chan source.Line)}
|
||||
d.Watches()[0].Source = src
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { d.Run(ctx); close(done) }()
|
||||
fail := func(addr string, n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
src.ch <- source.Line{Text: "Failed password for root from " + addr + " port 4000 ssh2", Time: time.Now()}
|
||||
}
|
||||
}
|
||||
fail("203.0.113.9", 2)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if hc.count() != 0 {
|
||||
t.Fatal("banned below the hits")
|
||||
}
|
||||
fail("203.0.113.9", 1)
|
||||
waitFor(t, "the first ban", func() bool { return hc.count() == 1 })
|
||||
if b := hc.last(); b.Address != "203.0.113.9" || b.Protocol != "tcp" || b.Ports != "22" || b.Time < 3590 || b.Time > 3600 || b.Source != "guard/ssh" ||
|
||||
!strings.Contains(b.Reason, "Failed password") {
|
||||
t.Errorf("ban %+v", b)
|
||||
}
|
||||
fail("203.0.113.9", 3)
|
||||
waitFor(t, "the repeat ban", func() bool { return hc.count() == 2 })
|
||||
if b := hc.last(); b.Time < 7190 || b.Time > 7200 {
|
||||
t.Errorf("a repeat offender's second ban: %ds, want 2h", b.Time)
|
||||
}
|
||||
fail("203.0.113.9", 3)
|
||||
waitFor(t, "the third ban", func() bool { return hc.count() == 3 })
|
||||
if b := hc.last(); b.Time < 10790 || b.Time > 10800 {
|
||||
t.Errorf("capped at max: %ds, want 3h", b.Time)
|
||||
}
|
||||
// IPv6, by /64.
|
||||
for _, a := range []string{"2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2::3"} {
|
||||
fail(a, 1)
|
||||
}
|
||||
waitFor(t, "the IPv6 ban", func() bool { return hc.count() == 4 })
|
||||
// never_ban.
|
||||
fail("198.51.100.7", 5)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if hc.count() != 4 {
|
||||
t.Errorf("never_ban banned: %+v", hc.last())
|
||||
}
|
||||
// Housecarl down: the ban waits, then goes.
|
||||
hc.setFail(errors.New("connection refused"))
|
||||
fail("203.0.113.20", 3)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if _, pending := st.Snapshot(); hc.count() != 4 || len(pending) != 1 {
|
||||
t.Errorf("while Housecarl is down: %d bans, %d pending", hc.count(), len(pending))
|
||||
}
|
||||
hc.setFail(nil)
|
||||
waitFor(t, "the ban that waited", func() bool { return hc.count() == 5 && hc.last().Address == "203.0.113.20" })
|
||||
// Refused: recorded, not sent again.
|
||||
hc.setFail(&housecarl.Error{Status: 422, Code: "never_ban", Message: "203.0.113.30 isn't banned: it is inside"})
|
||||
fail("203.0.113.30", 3)
|
||||
waitFor(t, "the refusal recorded", func() bool {
|
||||
recent, _ := st.Snapshot()
|
||||
return len(recent) > 0 && strings.Contains(recent[0].Result, "inside")
|
||||
})
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if _, pending := st.Snapshot(); len(pending) != 0 {
|
||||
t.Errorf("a refused ban waits: %+v", pending)
|
||||
}
|
||||
s := d.Status()
|
||||
if len(s.Watches) != 1 || s.Watches[0].Bans != 6 || s.Watches[0].Matches != 23 {
|
||||
t.Errorf("status %+v", s.Watches)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
// The state outlives the daemon.
|
||||
st2, err := state.Load(cfg.State)
|
||||
if err != nil || st2.Repeats("203.0.113.9/32", time.Now().Add(-time.Hour)) != 3 {
|
||||
t.Errorf("saved state: %v %+v", err, st2.Offences)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// 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
|
||||
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, 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
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package match finds the address in a log line: patterns are regular
|
||||
// expressions (Go's, RE2) with one <IP>, which matches an IPv4 or IPv6
|
||||
// address (bracketed, IPv4-mapped, with a zone). The address is checked
|
||||
// with netip, so what only looks like one (a time, 12:34:56) never is.
|
||||
package match
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Placeholder is where a pattern's address is.
|
||||
const Placeholder = "<IP>"
|
||||
|
||||
// ipRe matches an address: IPv4, or IPv6 (which may end in IPv4,
|
||||
// ::ffff:203.0.113.9), bracketed or not, with an optional zone.
|
||||
const ipRe = `\[?(?P<ip>[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){1,7}(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3})?(?:%[0-9A-Za-z_.-]+)?)\]?`
|
||||
|
||||
// Matcher holds a watch's patterns.
|
||||
type Matcher struct {
|
||||
match []*regexp.Regexp
|
||||
ignore []*regexp.Regexp
|
||||
}
|
||||
|
||||
// Compile makes a Matcher. Each match pattern must hold <IP> exactly
|
||||
// once; ignore patterns are plain regular expressions.
|
||||
func Compile(match, ignore []string) (*Matcher, error) {
|
||||
m := &Matcher{}
|
||||
for _, p := range match {
|
||||
if n := strings.Count(p, Placeholder); n != 1 {
|
||||
return nil, fmt.Errorf("pattern %q has %d %s; it needs exactly one, where the address is", p, n, Placeholder)
|
||||
}
|
||||
re, err := regexp.Compile(strings.Replace(p, Placeholder, ipRe, 1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pattern %q: %v", p, err)
|
||||
}
|
||||
m.match = append(m.match, re)
|
||||
}
|
||||
for _, p := range ignore {
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ignore pattern %q: %v", p, err)
|
||||
}
|
||||
m.ignore = append(m.ignore, re)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Match returns the address of the first pattern a line matches, unless
|
||||
// an ignore pattern matches it too.
|
||||
func (m *Matcher) Match(line string) (netip.Addr, bool) {
|
||||
for _, re := range m.match {
|
||||
sub := re.FindStringSubmatch(line)
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
text := sub[re.SubexpIndex("ip")]
|
||||
if i := strings.IndexByte(text, '%'); i >= 0 {
|
||||
text = text[:i]
|
||||
}
|
||||
a, err := netip.ParseAddr(text)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ig := range m.ignore {
|
||||
if ig.MatchString(line) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
}
|
||||
return a.Unmap(), true
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// Group is the prefix an address is counted and banned by: an IPv4
|
||||
// address alone, an IPv6 address by its /v6 (64: what one customer
|
||||
// usually has).
|
||||
func Group(a netip.Addr, v6 int) netip.Prefix {
|
||||
if a.Is4() || v6 <= 0 || v6 > 128 {
|
||||
if a.Is4() {
|
||||
return netip.PrefixFrom(a, 32)
|
||||
}
|
||||
v6 = 128
|
||||
}
|
||||
p, _ := a.Prefix(v6)
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
m, err := Compile([]string{`from <IP> port`, `client: <IP>,`, `^conn <IP>$`}, []string{`trusted-scanner`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"from 203.0.113.9 port 22": "203.0.113.9",
|
||||
"from 2001:db8::9 port 22": "2001:db8::9",
|
||||
"from ::ffff:203.0.113.9 port 22": "203.0.113.9",
|
||||
"from [2001:db8::9] port 22": "2001:db8::9",
|
||||
"from fe80::1%eth0 port 22": "fe80::1",
|
||||
"client: 203.0.113.9, server": "203.0.113.9",
|
||||
"conn 2001:db8:1:2:3:4:5:6": "2001:db8:1:2:3:4:5:6",
|
||||
"from 12:34:56 port 22": "", // a time, not an address
|
||||
"from 999.1.1.1 port 22": "",
|
||||
"from 203.0.113.9 port 22 trusted-scanner": "", // ignored
|
||||
"nothing here": "",
|
||||
}
|
||||
for line, want := range cases {
|
||||
a, ok := m.Match(line)
|
||||
if got := map[bool]string{true: a.String(), false: ""}[ok]; got != want {
|
||||
t.Errorf("%q: %q, want %q", line, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := Compile([]string{"<IP> and <IP>"}, nil); err == nil {
|
||||
t.Error("two <IP> accepted")
|
||||
}
|
||||
if _, err := Compile([]string{"<IP> (unclosed"}, nil); err == nil {
|
||||
t.Error("a broken pattern accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroup(t *testing.T) {
|
||||
if g := Group(netip.MustParseAddr("2001:db8:1:2:aa::1"), 64); g.String() != "2001:db8:1:2::/64" {
|
||||
t.Errorf("/64: %s", g)
|
||||
}
|
||||
if g := Group(netip.MustParseAddr("203.0.113.9"), 64); g.String() != "203.0.113.9/32" {
|
||||
t.Errorf("IPv4: %s", g)
|
||||
}
|
||||
if g := Group(netip.MustParseAddr("2001:db8::1"), 128); g.String() != "2001:db8::1/128" {
|
||||
t.Errorf("/128: %s", g)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// Package source reads the lines Guard watches: log files followed like
|
||||
// tail -F, the systemd journal (through journalctl), and Housecarl's log
|
||||
// of dropped packets.
|
||||
package source
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"guard/internal/housecarl"
|
||||
)
|
||||
|
||||
// Line is one line of a log, with its time: when it was written (the
|
||||
// journal, Housecarl's log) or read (a file).
|
||||
type Line struct {
|
||||
Text string
|
||||
Time time.Time
|
||||
// Addr is the address, for sources that know it without a pattern
|
||||
// (Housecarl's log).
|
||||
Addr netip.Addr
|
||||
}
|
||||
|
||||
// Source sends its lines until ctx ends.
|
||||
type Source interface {
|
||||
Run(ctx context.Context, out chan<- Line) error
|
||||
}
|
||||
|
||||
// maxLine is the longest line kept; longer ones are cut.
|
||||
const maxLine = 64 << 10
|
||||
|
||||
// ---- files
|
||||
|
||||
// File follows a file, or every file a glob matches, from its end: lines
|
||||
// written from now on. A file that appears later is read from its start;
|
||||
// one that is rotated (replaced) is finished, then the new one is read;
|
||||
// one that is truncated is read again from its start.
|
||||
type File struct {
|
||||
Pattern string
|
||||
// Poll is how often the files are checked (default 1s).
|
||||
Poll time.Duration
|
||||
Log *slog.Logger
|
||||
// Now gives the lines' time (tests).
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type tailer struct {
|
||||
path string
|
||||
f *os.File
|
||||
info os.FileInfo
|
||||
partial []byte
|
||||
}
|
||||
|
||||
// Run follows the files.
|
||||
func (s *File) Run(ctx context.Context, out chan<- Line) error {
|
||||
poll := s.Poll
|
||||
if poll == 0 {
|
||||
poll = time.Second
|
||||
}
|
||||
now := s.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
tails := map[string]*tailer{}
|
||||
defer func() {
|
||||
for _, t := range tails {
|
||||
if t.f != nil {
|
||||
t.f.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
first, warned := true, false
|
||||
for {
|
||||
paths, _ := filepath.Glob(s.Pattern)
|
||||
if len(paths) == 0 && !warned && s.Log != nil {
|
||||
s.Log.Warn("no file to watch yet; checking every few seconds", "file", s.Pattern)
|
||||
warned = true
|
||||
}
|
||||
for _, p := range paths {
|
||||
if _, ok := tails[p]; !ok {
|
||||
t := &tailer{path: p}
|
||||
// Files there when Guard starts are read from their end,
|
||||
// files that appear later from their start.
|
||||
if err := t.open(first); err != nil {
|
||||
if s.Log != nil {
|
||||
s.Log.Warn("can't read the file", "file", p, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
tails[p] = t
|
||||
warned = false
|
||||
}
|
||||
}
|
||||
first = false
|
||||
for p, t := range tails {
|
||||
if err := t.step(ctx, out, now); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
if s.Log != nil {
|
||||
s.Log.Warn("reading the file failed", "file", p, "err", err)
|
||||
}
|
||||
t.f.Close()
|
||||
delete(tails, p)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tailer) open(atEnd bool) error {
|
||||
f, err := os.Open(t.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if atEnd {
|
||||
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
t.f, t.info, t.partial = f, info, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// step sends what was written since the last step, and follows a
|
||||
// rotation or truncation.
|
||||
func (t *tailer) step(ctx context.Context, out chan<- Line, now func() time.Time) error {
|
||||
if err := t.drain(ctx, out, now); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(t.path)
|
||||
if err != nil {
|
||||
// Rotated away, and the new file isn't there yet: keep the old.
|
||||
return nil
|
||||
}
|
||||
if !os.SameFile(info, t.info) {
|
||||
t.f.Close()
|
||||
return t.open(false)
|
||||
}
|
||||
if pos, err := t.f.Seek(0, io.SeekCurrent); err == nil && info.Size() < pos {
|
||||
// Truncated (copytruncate): from the start again.
|
||||
if _, err := t.f.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
t.partial = nil
|
||||
t.info = info
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tailer) drain(ctx context.Context, out chan<- Line, now func() time.Time) error {
|
||||
buf := make([]byte, 32<<10)
|
||||
for {
|
||||
n, err := t.f.Read(buf)
|
||||
if n > 0 {
|
||||
data := append(t.partial, buf[:n]...)
|
||||
for {
|
||||
i := bytes.IndexByte(data, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line := data[:i]
|
||||
data = data[i+1:]
|
||||
if len(line) > maxLine {
|
||||
line = line[:maxLine]
|
||||
}
|
||||
select {
|
||||
case out <- Line{Text: strings.TrimRight(string(line), "\r"), Time: now()}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
if len(data) > maxLine {
|
||||
data = data[:maxLine]
|
||||
}
|
||||
t.partial = append([]byte(nil), data...)
|
||||
}
|
||||
if err == io.EOF || n == 0 {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the journal
|
||||
|
||||
// Journal follows the systemd journal's entries of some units and/or
|
||||
// syslog identifiers, through journalctl (no libsystemd: Guard has no
|
||||
// cgo). It starts with what is written from now on; when journalctl ends
|
||||
// it is started again after the last entry seen.
|
||||
type Journal struct {
|
||||
Units, Identifiers []string
|
||||
// Command is journalctl (GUARD_JOURNALCTL overrides it, for tests).
|
||||
Command string
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// JournalCommand is the journalctl to run.
|
||||
func JournalCommand() string {
|
||||
if c := os.Getenv("GUARD_JOURNALCTL"); c != "" {
|
||||
return c
|
||||
}
|
||||
return "journalctl"
|
||||
}
|
||||
|
||||
// JournalArgs are journalctl's arguments: following from now, or after a
|
||||
// cursor; or (follow false) the last n entries.
|
||||
func JournalArgs(units, idents []string, follow bool, cursor string, n int) []string {
|
||||
args := []string{"--output=json", "--no-pager"}
|
||||
switch {
|
||||
case !follow:
|
||||
args = append(args, "--lines="+strconv.Itoa(n))
|
||||
case cursor != "":
|
||||
args = append(args, "--follow", "--lines=all", "--after-cursor="+cursor)
|
||||
default:
|
||||
args = append(args, "--follow", "--lines=0")
|
||||
}
|
||||
for _, u := range units {
|
||||
args = append(args, "--unit="+u)
|
||||
}
|
||||
for _, i := range idents {
|
||||
args = append(args, "--identifier="+i)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Run follows the journal.
|
||||
func (s *Journal) Run(ctx context.Context, out chan<- Line) error {
|
||||
cmd := s.Command
|
||||
if cmd == "" {
|
||||
cmd = JournalCommand()
|
||||
}
|
||||
cursor := ""
|
||||
for {
|
||||
c := exec.CommandContext(ctx, cmd, JournalArgs(s.Units, s.Identifiers, true, cursor, 0)...)
|
||||
var stderr bytes.Buffer
|
||||
c.Stderr = &stderr
|
||||
stdout, err := c.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Start(); err != nil {
|
||||
return fmt.Errorf("starting %s: %w", cmd, err)
|
||||
}
|
||||
cursor = ReadJournal(ctx, stdout, out, cursor)
|
||||
err = c.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if s.Log != nil {
|
||||
s.Log.Warn("journalctl ended; starting it again in 5 seconds", "err", err, "stderr", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadJournal sends the messages of journalctl's JSON output, and returns
|
||||
// the last entry's cursor (or cursor, if there was none).
|
||||
func ReadJournal(ctx context.Context, r io.Reader, out chan<- Line, cursor string) string {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for sc.Scan() {
|
||||
l, c, ok := ParseJournal(sc.Bytes())
|
||||
if c != "" {
|
||||
cursor = c
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case out <- l:
|
||||
case <-ctx.Done():
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
// ParseJournal reads one entry of journalctl --output=json: its message
|
||||
// (a string, or bytes when it isn't UTF-8), time and cursor.
|
||||
func ParseJournal(data []byte) (Line, string, bool) {
|
||||
var e struct {
|
||||
Message json.RawMessage `json:"MESSAGE"`
|
||||
Cursor string `json:"__CURSOR"`
|
||||
Realtime string `json:"__REALTIME_TIMESTAMP"`
|
||||
}
|
||||
if json.Unmarshal(data, &e) != nil {
|
||||
return Line{}, "", false
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(e.Message, &text) != nil {
|
||||
var b []byte
|
||||
var ints []int
|
||||
if json.Unmarshal(e.Message, &ints) != nil {
|
||||
return Line{}, e.Cursor, false
|
||||
}
|
||||
for _, n := range ints {
|
||||
b = append(b, byte(n))
|
||||
}
|
||||
text = string(b)
|
||||
}
|
||||
if len(text) > maxLine {
|
||||
text = text[:maxLine]
|
||||
}
|
||||
t := time.Now()
|
||||
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
|
||||
t = time.UnixMicro(us)
|
||||
}
|
||||
return Line{Text: text, Time: t}, e.Cursor, true
|
||||
}
|
||||
|
||||
// ---- Housecarl's log
|
||||
|
||||
// LogReader reads Housecarl's log of packets.
|
||||
type LogReader interface {
|
||||
Log(ctx context.Context, after time.Time) ([]housecarl.LogEntry, error)
|
||||
}
|
||||
|
||||
// HousecarlDrops follows the packets Housecarl's rules dropped (or
|
||||
// rejected) and logged, from now on. Protocol limits them ("tcp",
|
||||
// "udp", "any"); Shadow counts shadow mode's, which weren't dropped.
|
||||
type HousecarlDrops struct {
|
||||
Client LogReader
|
||||
Protocol string
|
||||
Shadow bool
|
||||
Poll time.Duration
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Run follows the log.
|
||||
func (s *HousecarlDrops) Run(ctx context.Context, out chan<- Line) error {
|
||||
poll := s.Poll
|
||||
if poll == 0 {
|
||||
poll = 2 * time.Second
|
||||
}
|
||||
after := time.Now()
|
||||
failing := false
|
||||
for {
|
||||
entries, err := s.Client.Log(ctx, after)
|
||||
switch {
|
||||
case err != nil && !failing:
|
||||
if s.Log != nil {
|
||||
s.Log.Warn("reading Housecarl's log failed (the token needs log:read); trying again", "err", err)
|
||||
}
|
||||
failing = true
|
||||
case err == nil:
|
||||
failing = false
|
||||
// Newest first: send them oldest first.
|
||||
for i := len(entries) - 1; i >= 0; i-- {
|
||||
e := entries[i]
|
||||
if e.Time.After(after) {
|
||||
after = e.Time
|
||||
}
|
||||
if l, ok := s.line(e); ok {
|
||||
select {
|
||||
case out <- l:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HousecarlDrops) line(e housecarl.LogEntry) (Line, bool) {
|
||||
if e.Verdict != "drop" && e.Verdict != "reject" || e.Shadow && !s.Shadow {
|
||||
return Line{}, false
|
||||
}
|
||||
if p := strings.ToLower(s.Protocol); p != "any" && p != "" && e.Protocol != p {
|
||||
return Line{}, false
|
||||
}
|
||||
a, err := netip.ParseAddr(e.Source)
|
||||
if err != nil {
|
||||
return Line{}, false
|
||||
}
|
||||
text := fmt.Sprintf("%s %s %s -> %s", strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(e.Prefix), ":")), e.Protocol,
|
||||
hostPort(e.Source, e.SrcPort), hostPort(e.Dest, e.DestPort))
|
||||
return Line{Text: text, Time: e.Time, Addr: a.Unmap()}, true
|
||||
}
|
||||
|
||||
func hostPort(a string, p int) string {
|
||||
if p == 0 {
|
||||
return a
|
||||
}
|
||||
if strings.Contains(a, ":") {
|
||||
return "[" + a + "]:" + strconv.Itoa(p)
|
||||
}
|
||||
return a + ":" + strconv.Itoa(p)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"guard/internal/housecarl"
|
||||
)
|
||||
|
||||
// collect gathers lines until want of them came, or a timeout.
|
||||
func collect(t *testing.T, ch <-chan Line, want int) []string {
|
||||
t.Helper()
|
||||
var got []string
|
||||
deadline := time.After(5 * time.Second)
|
||||
for len(got) < want {
|
||||
select {
|
||||
case l := <-ch:
|
||||
got = append(got, l.Text)
|
||||
case <-deadline:
|
||||
t.Fatalf("got %d lines, want %d: %q", len(got), want, got)
|
||||
}
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func appendTo(t *testing.T, path, text string) {
|
||||
t.Helper()
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.WriteString(text)
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// TestFile: from the end of a file there at the start; a partial line
|
||||
// waits for its end; rotation finishes the old file and reads the new
|
||||
// from its start; truncation starts again; a file that appears later is
|
||||
// read from its start.
|
||||
func TestFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
log := filepath.Join(dir, "auth.log")
|
||||
appendTo(t, log, "old line, not read\n")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan Line, 100)
|
||||
go (&File{Pattern: filepath.Join(dir, "*.log"), Poll: 20 * time.Millisecond}).Run(ctx, ch)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
appendTo(t, log, "one\ntw")
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
appendTo(t, log, "o\r\n")
|
||||
if got := collect(t, ch, 2); strings.Join(got, ",") != "one,two" {
|
||||
t.Errorf("appended: %q", got)
|
||||
}
|
||||
// Rotation: a last line in the old file, then a new file.
|
||||
appendTo(t, log, "last of old\n")
|
||||
os.Rename(log, log+".1")
|
||||
appendTo(t, log, "first of new\n")
|
||||
if got := collect(t, ch, 2); strings.Join(got, ",") != "last of old,first of new" {
|
||||
t.Errorf("rotated: %q", got)
|
||||
}
|
||||
// Truncation (copytruncate): seen while the file is shorter than what
|
||||
// was read.
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
os.WriteFile(log, []byte("trunc\n"), 0o644)
|
||||
if got := collect(t, ch, 1); got[0] != "trunc" {
|
||||
t.Errorf("truncated: %q", got)
|
||||
}
|
||||
// A file that appears later.
|
||||
appendTo(t, filepath.Join(dir, "other.log"), "new file\n")
|
||||
if got := collect(t, ch, 1); got[0] != "new file" {
|
||||
t.Errorf("new file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJournal: journalctl's JSON through a fake journalctl, started
|
||||
// again after the last cursor when it ends.
|
||||
func TestJournal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
args := filepath.Join(dir, "args")
|
||||
fake := filepath.Join(dir, "journalctl")
|
||||
os.WriteFile(fake, []byte(`#!/bin/sh
|
||||
echo "$*" >> `+args+`
|
||||
echo '{"MESSAGE":"Failed password for root from 203.0.113.9 port 22 ssh2","__CURSOR":"c1","__REALTIME_TIMESTAMP":"1761127200000000"}'
|
||||
echo '{"MESSAGE":[104,105,255],"__CURSOR":"c2","__REALTIME_TIMESTAMP":"1761127201000000"}'
|
||||
echo 'not json'
|
||||
`), 0o755)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan Line, 100)
|
||||
go (&Journal{Units: []string{"ssh.service"}, Identifiers: []string{"sshd", "sshd-session"}, Command: fake}).Run(ctx, ch)
|
||||
got := collect(t, ch, 2)
|
||||
if got[0] != "Failed password for root from 203.0.113.9 port 22 ssh2" || got[1] != "hi\xff" {
|
||||
t.Errorf("lines %q", got)
|
||||
}
|
||||
// It ends; after 5 seconds it starts again after c2.
|
||||
time.Sleep(5500 * time.Millisecond)
|
||||
b, _ := os.ReadFile(args)
|
||||
calls := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
if len(calls) < 2 || !strings.Contains(calls[0], "--follow --lines=0 --unit=ssh.service --identifier=sshd --identifier=sshd-session") ||
|
||||
!strings.Contains(calls[1], "--after-cursor=c2") {
|
||||
t.Errorf("journalctl's arguments: %q", calls)
|
||||
}
|
||||
l, _, _ := ParseJournal([]byte(`{"MESSAGE":"x","__REALTIME_TIMESTAMP":"1761127200000000"}`))
|
||||
if l.Time.Unix() != 1761127200 {
|
||||
t.Errorf("time %v", l.Time)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLog struct{ entries []housecarl.LogEntry }
|
||||
|
||||
func (f *fakeLog) Log(_ context.Context, after time.Time) ([]housecarl.LogEntry, error) {
|
||||
var out []housecarl.LogEntry
|
||||
for _, e := range f.entries {
|
||||
if e.Time.After(after) {
|
||||
out = append([]housecarl.LogEntry{e}, out...) // newest first
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TestHousecarlDrops: drops and rejects of the protocol, not accepts or
|
||||
// shadow mode's, each with its source address.
|
||||
func TestHousecarlDrops(t *testing.T) {
|
||||
now := time.Now().Add(time.Second)
|
||||
f := &fakeLog{entries: []housecarl.LogEntry{
|
||||
{Time: now, Prefix: "hc input #3 drop: ", Protocol: "tcp", Source: "203.0.113.9", SrcPort: 4000, Dest: "198.51.100.1", DestPort: 22, Verdict: "drop"},
|
||||
{Time: now.Add(time.Millisecond), Protocol: "udp", Source: "203.0.113.10", Verdict: "drop"},
|
||||
{Time: now.Add(2 * time.Millisecond), Protocol: "tcp", Source: "203.0.113.11", Verdict: "accept"},
|
||||
{Time: now.Add(3 * time.Millisecond), Protocol: "tcp", Source: "203.0.113.12", Verdict: "drop", Shadow: true},
|
||||
{Time: now.Add(4 * time.Millisecond), Protocol: "tcp", Source: "2001:db8::9", Verdict: "reject"},
|
||||
}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan Line, 100)
|
||||
go (&HousecarlDrops{Client: f, Protocol: "tcp", Poll: 20 * time.Millisecond}).Run(ctx, ch)
|
||||
var got []string
|
||||
for len(got) < 2 {
|
||||
select {
|
||||
case l := <-ch:
|
||||
got = append(got, l.Addr.String()+" "+l.Text)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("lines %q", got)
|
||||
}
|
||||
}
|
||||
if got[0] != "203.0.113.9 hc input #3 drop tcp 203.0.113.9:4000 -> 198.51.100.1:22" || !strings.HasPrefix(got[1], "2001:db8::9 ") {
|
||||
t.Errorf("lines %q", got)
|
||||
}
|
||||
select {
|
||||
case l := <-ch:
|
||||
t.Errorf("more: %+v", l)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package state keeps what Guard must remember across a restart: the
|
||||
// bans it made (for repeat offenders' longer bans, and guard status),
|
||||
// and the bans Housecarl hasn't taken yet.
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxRecent is how many recent bans are kept for guard status.
|
||||
const MaxRecent = 200
|
||||
|
||||
// Ban is a ban Guard made (or is waiting to make).
|
||||
type Ban struct {
|
||||
Time time.Time `json:"time"`
|
||||
Watch string `json:"watch"`
|
||||
List string `json:"list"`
|
||||
Address string `json:"address"`
|
||||
// Group is what is counted for repeats: the address, or its IPv6
|
||||
// prefix.
|
||||
Group string `json:"group"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Ports string `json:"ports,omitempty"`
|
||||
Seconds int `json:"seconds"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Result is what Housecarl said: "banned", "renewed", or why not.
|
||||
Result string `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// State is Guard's memory.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
// Offences are the times each group was banned.
|
||||
Offences map[string][]time.Time `json:"offences"`
|
||||
Recent []Ban `json:"recent"`
|
||||
Pending []Ban `json:"pending"`
|
||||
}
|
||||
|
||||
// Load reads the state (an empty one if the file isn't there).
|
||||
func Load(path string) (*State, error) {
|
||||
s := &State{path: path, Offences: map[string][]time.Time{}}
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Offences == nil {
|
||||
s.Offences = map[string][]time.Time{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Save writes the state atomically.
|
||||
func (s *State) Save() error {
|
||||
s.mu.Lock()
|
||||
data, err := json.Marshal(s)
|
||||
s.mu.Unlock()
|
||||
if err != nil || s.path == "" {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// Repeats counts a group's bans since a time.
|
||||
func (s *State) Repeats(group string, since time.Time) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, t := range s.Offences[group] {
|
||||
if t.After(since) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Banned records a ban Housecarl made.
|
||||
func (s *State) Banned(b Ban) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Offences[b.Group] = append(s.Offences[b.Group], b.Time)
|
||||
s.recent(b)
|
||||
}
|
||||
|
||||
// Failed records a ban Housecarl refused.
|
||||
func (s *State) Failed(b Ban) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.recent(b)
|
||||
}
|
||||
|
||||
func (s *State) recent(b Ban) {
|
||||
s.Recent = append(s.Recent, b)
|
||||
if len(s.Recent) > MaxRecent {
|
||||
s.Recent = append([]Ban(nil), s.Recent[len(s.Recent)-MaxRecent:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// SetPending replaces the bans waiting for Housecarl.
|
||||
func (s *State) SetPending(p []Ban) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Pending = append([]Ban(nil), p...)
|
||||
}
|
||||
|
||||
// Snapshot returns the recent bans (newest first) and the pending ones.
|
||||
func (s *State) Snapshot() (recent, pending []Ban) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := len(s.Recent) - 1; i >= 0; i-- {
|
||||
recent = append(recent, s.Recent[i])
|
||||
}
|
||||
return recent, append([]Ban(nil), s.Pending...)
|
||||
}
|
||||
|
||||
// Forget drops offences older than a time.
|
||||
func (s *State) Forget(before time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for g, ts := range s.Offences {
|
||||
var keep []time.Time
|
||||
for _, t := range ts {
|
||||
if t.After(before) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
delete(s.Offences, g)
|
||||
} else {
|
||||
s.Offences[g] = keep
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package version holds Guard's version, set at build time with
|
||||
//
|
||||
// go build -ldflags "-X guard/internal/version.Version=1.2.3 -X guard/internal/version.Tag=v1.2.3"
|
||||
//
|
||||
// The Makefile sets Version from the VERSION file, and Tag only when the
|
||||
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
|
||||
// is changed. Only such a build reports a version number. Other builds
|
||||
// are development builds, identified by their commit (which Go records
|
||||
// from git by itself) and by where they stand relative to the last tag
|
||||
// (Describe, from git describe).
|
||||
package version
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Version is the Guard version (the VERSION file).
|
||||
var Version = "0.1.0-dev"
|
||||
|
||||
// Tag is the release tag the build is of ("" when it isn't a release).
|
||||
var Tag = ""
|
||||
|
||||
// Commit is the git commit the build is of. It is read from the build
|
||||
// information when not set at build time.
|
||||
var Commit = ""
|
||||
|
||||
// Describe is 'git describe --tags --long' of the build's commit
|
||||
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
|
||||
// commits a development build is past the last tag.
|
||||
var Describe = ""
|
||||
|
||||
// Repo is Guard's source repository.
|
||||
const Repo = "https://gitea.adbraun.lol/adam/guard"
|
||||
|
||||
// Build describes what a binary was built from.
|
||||
type Build struct {
|
||||
Version string `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
|
||||
// Since is the last tag before a development build's commit, and
|
||||
// Ahead how many commits the build is past it (0: the tag's commit,
|
||||
// with uncommitted changes).
|
||||
Since string `json:"since,omitempty"`
|
||||
Ahead int `json:"ahead,omitempty"`
|
||||
// Label is the short form to show: the tag of a release, otherwise
|
||||
// the abbreviated commit (or the version when the commit is unknown).
|
||||
Label string `json:"label"`
|
||||
// URL is the release or commit in the repository.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
var info = sync.OnceValue(func() Build {
|
||||
commit, modified := Commit, false
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if commit == "" {
|
||||
commit = s.Value
|
||||
}
|
||||
case "vcs.modified":
|
||||
modified = s.Value == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
b := describe(Version, Tag, commit, modified)
|
||||
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
|
||||
return b
|
||||
})
|
||||
|
||||
// parseDescribe splits git describe --long output, if it is of commit.
|
||||
func parseDescribe(d, commit string) (since string, ahead int) {
|
||||
m := describeRe.FindStringSubmatch(d)
|
||||
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
|
||||
return "", 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
return m[1], n
|
||||
}
|
||||
|
||||
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
|
||||
|
||||
func describe(version, tag, commit string, modified bool) Build {
|
||||
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
|
||||
switch {
|
||||
case tag != "":
|
||||
b.Modified = false // the Makefile only sets Tag for clean trees
|
||||
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
|
||||
case commit != "":
|
||||
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
|
||||
if modified {
|
||||
b.Label += "-modified"
|
||||
}
|
||||
default:
|
||||
b.Label, b.URL = version, Repo
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Info describes this build.
|
||||
func Info() Build { return info() }
|
||||
|
||||
// String is the version for logs and 'version' commands. Only a release
|
||||
// build gives a version ("0.2.0"); any other build says it's a
|
||||
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
|
||||
func String() string { return Info().String() }
|
||||
|
||||
func (b Build) String() string {
|
||||
switch {
|
||||
case b.Tag != "":
|
||||
return b.Version
|
||||
case b.Commit == "":
|
||||
// No git information (built from a source archive).
|
||||
return b.Version + " (source build)"
|
||||
}
|
||||
s := "dev " + b.Label
|
||||
switch {
|
||||
case b.Since == "":
|
||||
case b.Ahead == 1:
|
||||
s += " (1 commit after " + b.Since + ")"
|
||||
case b.Ahead > 1:
|
||||
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
|
||||
case b.Modified:
|
||||
s += " (" + b.Since + " with uncommitted changes)"
|
||||
default:
|
||||
s += " (at " + b.Since + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Short is the version for a User-Agent: the version of a release,
|
||||
// otherwise the label ("a1b2c3d4e5f6").
|
||||
func Short() string {
|
||||
if b := Info(); b.Tag == "" && b.Commit != "" {
|
||||
return b.Label
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
Reference in new issue
Block a user