7 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 9bb5155417 Release 0.1.0-rc.2
release / release (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:32:20 +00:00
abrown38andClaude Opus 5.5 ecf4129877 Reload guard.yaml without losing the bounties
systemctl reload guard, guard reload or SIGHUP reads the configuration
again: a watch keeps its bounties and counts under the same name and
takes its new settings at once; its source starts again only if it
changed; watches gone stop, new ones start; a file with a mistake is
refused. The Housecarl connection, state, socket and log wait for a
restart, and guard reload says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:32:20 +00:00
abrown38andClaude Opus 5.5 9c655abae2 Release 0.1.0-rc.1+git7-b87cc6f
release / release (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:53:37 +01:00
abrown38andClaude Opus 5.5 b87cc6fb62 CLI: shorter, plainer output
Tables name a journal source by its first two units or identifiers;
guard test shows 20 matching lines and addresses (--all for all), no
/32; Housecarl connection errors without Go's request URL, said once by
guard check; status tells "not running" from "no permission"; bans says
when there are none; "1 ban lifted".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:53:37 +01:00
abrown38andClaude Opus 5.5 96f551be80 Release 0.1.0-rc.1+git5-73ab640
release / release (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:47:17 +01:00
abrown38andClaude Opus 5.5 73ab6405aa Release script: encode + in the tag and asset names
A build of a candidate (0.1.0-rc.1+git4-8fa62f9) put a raw + in the
release lookup and the upload's ?name=, where it reads as a space.
Housecarl's script has the same fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:47:17 +01:00
abrown38andClaude Opus 5.5 8fa62f9b91 Package: put the guard user in adm and systemd-journal
Only the unit's SupplementaryGroups gave them, so 'sudo -u guard guard
test' and 'guard check' could read neither /var/log's files nor the
journal. systemd-sysusers (run on every install and upgrade) adds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:46:53 +01:00
16 changed files with 781 additions and 122 deletions

No files matched your search

+28 -2
View File
@@ -22,9 +22,35 @@ All notable changes to Guard are documented here. The format follows
`postfix-sasl`, `dovecot`, `nginx-auth`, `housecarl-drops` `postfix-sasl`, `dovecot`, `nginx-auth`, `housecarl-drops`
(Housecarl's log of dropped packets, TCP only by default). (Housecarl's log of dropped packets, TCP only by default).
- Commands: `guard run`, `check`, `config [--example]`, `presets`, - Commands: `guard run`, `check`, `config [--example]`, `presets`,
`test` (a watch's patterns over a log), `status` (through a local `test` (a watch's patterns over a log), `status` and `reload` (through
socket), `bans`, `ban`, `unban`, `version`. a local socket), `bans`, `ban`, `unban`, `version`.
- Reloading: `systemctl reload guard`, `guard reload` or SIGHUP reads
guard.yaml again without losing the bounties. A watch keeps its
bounties under the same name and takes its new settings at once; its
source starts again only if it changed; watches gone stop, new ones
start. A file with a mistake is refused. The Housecarl connection,
state, socket and log wait for a restart (`guard reload` says so).
- A Debian package with a sandboxed systemd unit, as the `guard` user in - A Debian package with a sandboxed systemd unit, as the `guard` user in
the `adm` and `systemd-journal` groups, published with each release to the `adm` and `systemd-journal` groups, published with each release to
the Gitea Debian registry (`stable`, or `testing` for a release the Gitea Debian registry (`stable`, or `testing` for a release
candidate) along with tarballs for Linux amd64 and arm64. candidate) along with tarballs for Linux amd64 and arm64.
### Fixed
- The package's `guard` user is now in the `adm` and `systemd-journal`
groups itself, as the documentation said, not only through its unit's
`SupplementaryGroups`. `sudo -u guard guard test` and `guard check`
could read neither `/var/log`'s files (`permission denied`) nor the
journal. Upgrading adds the groups.
- Shorter, plainer command output. Tables name a journal source by its
first two units or identifiers (`journal postfix/smtpd,
postfix/submission/smtpd +1`), so `presets`, `check` and `status`
fit a normal terminal. `guard test` prints the first 20 matching
lines (not 200) and the first 20 addresses it would ban (`--all` for
every one), single addresses without `/32`, and counts the matches
from `never_ban` addresses. When Housecarl doesn't answer, `guard check`
says so once ("Housecarl at URL doesn't answer: connection refused")
instead of once per ban list with the request's URL, and only asks for
`log:read` when Housecarl refused the log. `guard status` tells "not
running" from "no permission". `guard bans` says when there are none;
`guard unban` says "1 ban lifted".
+3 -1
View File
@@ -49,6 +49,8 @@ watch:
* **IPv6 by /64**, as the ban list says: one customer usually has a * **IPv6 by /64**, as the ban list says: one customer usually has a
whole /64 and moves around in it. whole /64 and moves around in it.
* **Housecarl away?** Bans wait and are sent once it answers. * **Housecarl away?** Bans wait and are sent once it answers.
* **`systemctl reload guard`** puts a changed guard.yaml to work
without losing what Guard has counted.
* **`guard test`** shows what a watch's patterns find in a log, and whom * **`guard test`** shows what a watch's patterns find in a log, and whom
they would ban, before you trust it. they would ban, before you trust it.
@@ -65,4 +67,4 @@ Housecarl's side: its [docs/bans.md](https://gitea.adbraun.lol/adam/housecarl/sr
## Status ## Status
Guard 0.1.0 is in testing (0.1.0-rc.1). Go, `CGO_ENABLED=0`, Linux. MIT licence. Guard 0.1.0 is in testing (0.1.0-rc.2). Go, `CGO_ENABLED=0`, Linux. MIT licence.
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.1 0.1.0-rc.2
+174 -34
View File
@@ -14,6 +14,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@@ -52,6 +53,7 @@ keeps it from this machine (or from what the list says).
guard check check guard.yaml and reach Housecarl guard check check guard.yaml and reach Housecarl
guard test ssh /var/log/auth.log what a watch's patterns find in a log guard test ssh /var/log/auth.log what a watch's patterns find in a log
guard status the watches, their highest bounties, recent bans guard status the watches, their highest bounties, recent bans
guard reload read guard.yaml again, keeping the bounties
Exit codes: 0 ok, 1 error.`, Exit codes: 0 ok, 1 error.`,
SilenceUsage: true, SilenceUsage: true,
@@ -60,7 +62,7 @@ Exit codes: 0 ok, 1 error.`,
} }
root.PersistentFlags().StringVarP(&flagConfig, "config", "c", config.DefaultPath, "configuration file") root.PersistentFlags().StringVarP(&flagConfig, "config", "c", config.DefaultPath, "configuration file")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "print JSON instead of tables") root.PersistentFlags().BoolVar(&flagJSON, "json", false, "print JSON instead of tables")
root.AddCommand(runCmd(), checkCmd(), configCmd(), presetsCmd(), testCmd(), statusCmd(), bansCmd(), banCmd(), unbanCmd(), versionCmd()) root.AddCommand(runCmd(), checkCmd(), configCmd(), presetsCmd(), testCmd(), statusCmd(), reloadCmd(), bansCmd(), banCmd(), unbanCmd(), versionCmd())
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "guard:", err) fmt.Fprintln(os.Stderr, "guard:", err)
os.Exit(1) os.Exit(1)
@@ -131,8 +133,19 @@ func runCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
d.Load = load
c, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) c, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
// SIGHUP reloads, as guard reload does.
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for range hup {
if _, err := d.ReloadFile(); err != nil {
log.Error("reload refused; the configuration stays as it was", "err", err)
}
}
}()
log.Info("starting", "version", version.String(), "config", flagConfig) log.Info("starting", "version", version.String(), "config", flagConfig)
return d.Run(c) return d.Run(c)
}, },
@@ -161,7 +174,7 @@ func checkCmd() *cobra.Command {
} }
lists[x.Ban.List] = true lists[x.Ban.List] = true
drops = drops || x.Kind() == config.SourceHousecarlDrops drops = drops || x.Kind() == config.SourceHousecarlDrops
fmt.Fprintf(w, "%s\t%s\t%d\t%d in %s\t%s\t%s\n", x.Name, daemon.Describe(x), len(x.Match), x.Hits, x.Within, x.Ban.List, ports(x.Ban.Protocol, x.Ban.Port)) fmt.Fprintf(w, "%s\t%s\t%d\t%d in %s\t%s\t%s\n", x.Name, daemon.Short(x), len(x.Match), x.Hits, x.Within, x.Ban.List, ports(x.Ban.Protocol, x.Ban.Port))
} }
}) })
hc, err := client(cfg) hc, err := client(cfg)
@@ -169,9 +182,17 @@ func checkCmd() *cobra.Command {
return err return err
} }
bad := false bad := false
names := make([]string, 0, len(lists))
for l := range lists { for l := range lists {
names = append(names, l)
}
sort.Strings(names)
for _, l := range names {
bl, err := hc.BanList(ctx(), l) bl, err := hc.BanList(ctx(), l)
var down *housecarl.UnreachableError
switch { switch {
case errors.As(err, &down):
return err
case err != nil: case err != nil:
fmt.Printf("ban list %s: %v\n", l, err) fmt.Printf("ban list %s: %v\n", l, err)
bad = true bad = true
@@ -183,7 +204,11 @@ func checkCmd() *cobra.Command {
} }
if drops { if drops {
if _, err := hc.Log(ctx(), time.Now()); err != nil { if _, err := hc.Log(ctx(), time.Now()); err != nil {
fmt.Printf("Housecarl's log (housecarl-drops): %v (the token needs log:read)\n", err) hint := ""
if housecarl.Refused(err) {
hint = " (the token needs log:read)"
}
fmt.Printf("Housecarl's log (housecarl-drops): %v%s\n", err, hint)
bad = true bad = true
} else { } else {
fmt.Println("Housecarl's log (housecarl-drops): ok") fmt.Println("Housecarl's log (housecarl-drops): ok")
@@ -266,7 +291,7 @@ func presetsCmd() *cobra.Command {
fmt.Fprintln(w, "PRESET\tSOURCE\tWHAT") fmt.Fprintln(w, "PRESET\tSOURCE\tWHAT")
for _, n := range names { for _, n := range names {
p := ps[n] p := ps[n]
fmt.Fprintf(w, "%s\t%s\t%s\n", n, daemon.Describe(&config.Watch{File: p.File, Journal: p.Journal, Source: p.Source, Protocol: "tcp"}), p.Description) fmt.Fprintf(w, "%s\t%s\t%s\n", n, daemon.Short(&config.Watch{File: p.File, Journal: p.Journal, Source: p.Source, Protocol: "tcp"}), p.Description)
} }
}) })
return nil return nil
@@ -331,10 +356,14 @@ the watch's window (within) is used.`,
count int count int
ban bool ban bool
} }
type found struct {
addr, text, why string
}
hits := map[string]*hit{} hits := map[string]*hit{}
order := []string{} order := []string{}
tr := map[string][]time.Time{} tr := map[string][]time.Time{}
matched := 0 var shown []found
matched, spared := 0, 0
for _, l := range in { for _, l := range in {
a, ok := m.Match(l.Text) a, ok := m.Match(l.Text)
if !ok { if !ok {
@@ -342,17 +371,14 @@ the watch's window (within) is used.`,
} }
matched++ matched++
why := never.Why(a) why := never.Why(a)
if all || matched <= 200 { if all || len(shown) < testShown {
note := "" shown = append(shown, found{a.String(), l.Text, why})
if why != "" {
note = " (never banned: " + why + ")"
}
fmt.Printf("%-39s %s%s\n", a, clip(l.Text, 160), note)
} }
if why != "" { if why != "" {
spared++
continue continue
} }
g := match.Group(a, 64).String() g := address(match.Group(a, 64))
h := hits[g] h := hits[g]
if h == nil { if h == nil {
h = &hit{} h = &hit{}
@@ -371,39 +397,72 @@ the watch's window (within) is used.`,
h.ban = h.ban || h.count >= w.Hits h.ban = h.ban || h.count >= w.Hits
} }
} }
if matched > 200 && !all { width := 0
fmt.Printf("… %d more matching lines (--all shows them)\n", matched-200) for _, f := range shown {
width = max(width, len(f.addr))
} }
fmt.Printf("\n%d lines, %d matched, %d addresses (IPv6 by /64)\n", len(in), matched, len(order)) for _, f := range shown {
note := ""
if f.why != "" {
note = " (never banned: " + f.why + ")"
}
fmt.Printf("%-*s %s%s\n", width, f.addr, clip(f.text, 120), note)
}
if matched > len(shown) {
fmt.Printf("… and %d more matching lines (--all shows them)\n", matched-len(shown))
}
fmt.Printf("\n%d lines, %d matched, %s (IPv6 by /64)", len(in), matched, plural(len(order), "address"))
if spared > 0 {
fmt.Printf("; %d matches from never_ban addresses", spared)
}
fmt.Println()
sort.SliceStable(order, func(i, j int) bool { return hits[order[i]].count > hits[order[j]].count }) sort.SliceStable(order, func(i, j int) bool { return hits[order[i]].count > hits[order[j]].count })
banned := 0 var banned []string
for _, g := range order { for _, g := range order {
if hits[g].ban { if hits[g].ban {
banned++ banned = append(banned, g)
} }
} }
if banned == 0 { rule := fmt.Sprintf("%d hits within %s", w.Hits, w.Within)
fmt.Printf("nobody would be banned (%d hits within %s)\n", w.Hits, w.Within) if !timed {
rule = fmt.Sprintf("%d hits; a file's lines have no times, so all of them count", w.Hits)
}
if len(banned) == 0 {
fmt.Printf("Nobody would be banned (%s)\n", rule)
return nil return nil
} }
window := "within " + w.Within.String() fmt.Printf("Would ban %d (%s):\n", len(banned), rule)
if !timed { width = 0
window = "in all (a file's lines have no time)" for _, g := range banned {
width = max(width, len(g))
} }
fmt.Printf("would ban %d (%d hits %s):\n", banned, w.Hits, window) for i, g := range banned {
for _, g := range order { if i == testShown && !all {
if hits[g].ban { fmt.Printf(" … and %d more (--all shows them)\n", len(banned)-i)
fmt.Printf(" %-39s %d matches\n", g, hits[g].count) break
} }
fmt.Printf(" %-*s %s\n", width, g, plural(hits[g].count, "match"))
} }
return nil return nil
}, },
} }
cmd.Flags().IntVarP(&lines, "lines", "n", 5000, "a journal watch without FILE: the last this many entries") cmd.Flags().IntVarP(&lines, "lines", "n", 5000, "a journal watch without FILE: the last this many entries")
cmd.Flags().BoolVar(&all, "all", false, "print every matching line, not the first 200") cmd.Flags().BoolVar(&all, "all", false, "print every matching line and address, not the first 20")
return cmd return cmd
} }
// testShown is how many matching lines, and addresses to ban, guard
// test prints without --all.
const testShown = 20
// address is a ban's address: a single address without its /32 or /128.
func address(p netip.Prefix) string {
if p.IsSingleIP() {
return p.Addr().String()
}
return p.String()
}
func readLines(path string) ([]source.Line, error) { func readLines(path string) ([]source.Line, error) {
var r io.Reader = os.Stdin var r io.Reader = os.Stdin
if path != "-" { if path != "-" {
@@ -460,6 +519,32 @@ func clip(s string, n int) string {
// ---- status // ---- status
// askDaemon makes a request of the running Guard, on its socket.
func askDaemon(cfg *config.Config, method, path string) (*http.Response, error) {
hc := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{DialContext: func(c context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(c, "unix", cfg.Socket)
}}}
req, err := http.NewRequest(method, "http://guard"+path, nil)
if err != nil {
return nil, err
}
resp, err := hc.Do(req)
switch {
case errors.Is(err, os.ErrNotExist), errors.Is(err, syscall.ECONNREFUSED):
return nil, fmt.Errorf("Guard isn't running (nothing answers on %s)", cfg.Socket)
case errors.Is(err, os.ErrPermission):
return nil, fmt.Errorf("no permission for %s: run this as root or the guard user", cfg.Socket)
case err != nil:
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err
}
return nil, fmt.Errorf("Guard's socket %s: %w", cfg.Socket, err)
}
return resp, nil
}
func statusCmd() *cobra.Command { func statusCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "status", Short: "The running Guard: its watches, highest bounties, recent bans", Args: cobra.NoArgs, Use: "status", Short: "The running Guard: its watches, highest bounties, recent bans", Args: cobra.NoArgs,
@@ -468,13 +553,9 @@ func statusCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
hc := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{DialContext: func(c context.Context, _, _ string) (net.Conn, error) { resp, err := askDaemon(cfg, http.MethodGet, "/status")
var d net.Dialer
return d.DialContext(c, "unix", cfg.Socket)
}}}
resp, err := hc.Get("http://guard/status")
if err != nil { if err != nil {
return fmt.Errorf("Guard isn't running, or %s can't be reached (as root, or the guard user): %w", cfg.Socket, err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
var s daemon.Status var s daemon.Status
@@ -519,6 +600,61 @@ func statusCmd() *cobra.Command {
} }
} }
// ---- reload
func reloadCmd() *cobra.Command {
return &cobra.Command{
Use: "reload", Short: "Make the running Guard read guard.yaml again, keeping its bounties", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Checked here first, so a mistake is shown before Guard sees it.
cfg, err := load()
if err != nil {
return err
}
resp, err := askDaemon(cfg, http.MethodPost, "/reload")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var e struct{ Error string }
json.NewDecoder(resp.Body).Decode(&e)
return fmt.Errorf("reload refused: %s", e.Error)
}
var r daemon.Reloaded
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return err
}
if flagJSON {
return printJSON(r)
}
if len(r.Changes) == 0 {
fmt.Println("Reloaded: nothing changed")
} else {
fmt.Println("Reloaded:")
for _, c := range r.Changes {
fmt.Println(" " + c)
}
}
if len(r.Restart) > 0 {
fmt.Printf("Changed, but only on a restart (systemctl restart guard): %s\n", strings.Join(r.Restart, ", "))
}
return nil
},
}
}
// plural is "1 ban", "2 bans", "2 matches".
func plural(n int, what string) string {
if n == 1 {
return "1 " + what
}
if strings.HasSuffix(what, "s") || strings.HasSuffix(what, "ch") {
return strconv.Itoa(n) + " " + what + "es"
}
return strconv.Itoa(n) + " " + what + "s"
}
func splitComma(s string) []string { func splitComma(s string) []string {
if s == "" { if s == "" {
return nil return nil
@@ -562,6 +698,10 @@ func bansCmd() *cobra.Command {
if flagJSON { if flagJSON {
return printJSON(bans) return printJSON(bans)
} }
if len(bans) == 0 {
fmt.Printf("No active bans in %s\n", l)
return nil
}
table(func(w io.Writer) { table(func(w io.Writer) {
fmt.Fprintln(w, "ID\tADDRESS\tPORT\tENDS IN\tBY\tSOURCE") fmt.Fprintln(w, "ID\tADDRESS\tPORT\tENDS IN\tBY\tSOURCE")
for _, b := range bans { for _, b := range bans {
@@ -668,7 +808,7 @@ func unbanCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("%d bans lifted\n", n) fmt.Printf("%s lifted\n", plural(n, "ban"))
return nil return nil
}, },
} }
+2
View File
@@ -9,6 +9,8 @@ Wants=network-online.target
[Service] [Service]
ExecStart=/usr/local/bin/guard --config /etc/guard/guard.yaml run ExecStart=/usr/local/bin/guard --config /etc/guard/guard.yaml run
# systemctl reload guard: guard.yaml again, keeping the bounties.
ExecReload=/usr/local/bin/guard --config /etc/guard/guard.yaml reload
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
+5 -1
View File
@@ -1,3 +1,7 @@
# Guard's system user (systemd-sysusers): it reads logs and talks to # Guard's system user (systemd-sysusers): it reads logs and talks to
# Housecarl, nothing more. # Housecarl, nothing more. adm reads /var/log's files, systemd-journal
# the journal, for the daemon (its unit adds them too) and for
# 'sudo -u guard guard test'.
u guard - "Guard log watcher" /var/lib/guard /usr/sbin/nologin u guard - "Guard log watcher" /var/lib/guard /usr/sbin/nologin
m guard adm
m guard systemd-journal
+1
View File
@@ -12,6 +12,7 @@ One binary is the daemon and its tools. Every command reads
| `guard presets [PRESET]` | the presets, or one's patterns | | `guard presets [PRESET]` | the presets, or one's patterns |
| `guard test WATCH [FILE\|-] [--lines N] [--all]` | what a watch's patterns find in a log, and whom they would ban ([presets.md](presets.md)) | | `guard test WATCH [FILE\|-] [--lines N] [--all]` | what a watch's patterns find in a log, and whom they would ban ([presets.md](presets.md)) |
| `guard status` | the running Guard: each watch's lines, matches, bans and highest bounties, the recent bans and why any failed, and bans waiting for Housecarl. Asks the socket: as root or the guard user | | `guard status` | the running Guard: each watch's lines, matches, bans and highest bounties, the recent bans and why any failed, and bans waiting for Housecarl. Asks the socket: as root or the guard user |
| `guard reload` | make the running Guard read guard.yaml again, keeping its bounties, and show what changed (as `systemctl reload guard` does; [configuration.md](configuration.md#reloading)). Asks the socket |
| `guard bans [--list L]` | the ban list's active bans, from Housecarl | | `guard bans [--list L]` | the ban list's active bans, from Housecarl |
| `guard ban ADDRESS [--list L] [--protocol tcp --port 22]... [--time 1d] [--reason R]` | ban by hand | | `guard ban ADDRESS [--list L] [--protocol tcp --port 22]... [--time 1d] [--reason R]` | ban by hand |
| `guard unban ADDRESS [--list L]` | lift an address's bans | | `guard unban ADDRESS [--list L]` | lift an address's bans |
+26 -2
View File
@@ -3,8 +3,9 @@
`/etc/guard/guard.yaml` is Guard's whole configuration. `guard config `/etc/guard/guard.yaml` is Guard's whole configuration. `guard config
--example` prints a commented one. Unknown settings are an error; --example` prints a commented one. Unknown settings are an error;
`guard check` checks the file, compiles every pattern and asks Housecarl `guard check` checks the file, compiles every pattern and asks Housecarl
for the ban lists. Guard reads it when it starts: for the ban lists. After a change, `systemctl reload guard` (or `guard
`systemctl restart guard` after a change. reload`) puts it to work without losing what Guard has counted (see
[Reloading](#reloading)).
## housecarl ## housecarl
@@ -116,6 +117,29 @@ every 30 seconds, for the time it has left. A ban Housecarl refuses (an
address it never bans, a list the token doesn't cover) isn't sent again: address it never bans, a list the token doesn't cover) isn't sent again:
`guard status` and the log say why. `guard status` and the log say why.
## Reloading
`systemctl reload guard`, `guard reload` or a SIGHUP makes the running
Guard read guard.yaml again. `guard reload` checks the file first and
shows what changed; a file with a mistake is refused, and Guard goes on
as it was.
* A watch is known by its **name**: one that keeps its name keeps its
bounties and counts, and takes its new patterns, hits, window and ban
at once. If its source changed (another file, journal unit, …), the
source starts again; a file is then followed from its end, as when
Guard starts.
* Watches gone (or disabled) stop; new ones start.
* `never_ban` and `defaults.repeat` apply to the next matches and bans.
Bans already made stay as they are, in Housecarl.
* `housecarl`'s connection (`url`, `socket`, the token, `ca_cert`,
`insecure`), `state`, `socket` and `log` only change on a restart:
`guard reload` says so. `housecarl.list` reloads (it only fills in the
watches' lists).
Changing `hits` doesn't ban at once: an address over the new hits is
banned on its next match.
## state, socket, log ## state, socket, log
| Setting | Default | | | Setting | Default | |
+7
View File
@@ -46,6 +46,13 @@ func (t *Tracker) Add(p netip.Prefix, at time.Time) (int, bool) {
return len(ts), false return len(ts), false
} }
// Set changes the hits and window (a reload); the bounties stay.
func (t *Tracker) Set(hits int, within time.Duration) {
t.mu.Lock()
t.Hits, t.Within = hits, within
t.mu.Unlock()
}
// keep drops the times before from (times are in order). // keep drops the times before from (times are in order).
func keep(ts []time.Time, from time.Time) []time.Time { func keep(ts []time.Time, from time.Time) []time.Time {
i := 0 i := 0
+3 -3
View File
@@ -1,6 +1,6 @@
# Guard's configuration (docs/configuration.md). Guard reads it when it # Guard's configuration (docs/configuration.md). After a change,
# starts: systemctl restart guard after a change, and 'guard check' to # 'systemctl reload guard' (or 'guard reload') puts it to work and keeps
# check it first. # the bounties; 'guard check' checks it first.
# Housecarl, which does the banning. Give Guard a token for its ban list # Housecarl, which does the banning. Give Guard a token for its ban list
# alone; on the firewall: # alone; on the firewall:
+172 -69
View File
@@ -37,16 +37,20 @@ type Banner interface {
// Daemon is a running Guard. // Daemon is a running Guard.
type Daemon struct { type Daemon struct {
cfg *config.Config
log *slog.Logger log *slog.Logger
hc Banner hc Banner
logs source.LogReader
state *state.State state *state.State
never *NeverBan
watches []*Watch
started time.Time started time.Time
ctx context.Context // Run's, for what a reload starts
mu sync.Mutex mu sync.Mutex
groups map[string]int // ban list → IPv6 grouping // cfg, never and watches change on a reload.
cfg *config.Config
never *NeverBan
neverStop context.CancelFunc
watches []*Watch
groups map[string]int // ban list → IPv6 grouping
// pending are the bans Housecarl hasn't taken yet, by list and group. // pending are the bans Housecarl hasn't taken yet, by list and group.
pending map[string]state.Ban pending map[string]state.Ban
wake chan struct{} wake chan struct{}
@@ -54,6 +58,11 @@ type Daemon struct {
// once an hour. // once an hour.
refused map[string]time.Time refused map[string]time.Time
// Load reads the configuration again for a reload (guard run sets it).
Load func() (*config.Config, error)
// Sources makes a watch's source (tests); nil: from its configuration.
Sources func(*config.Watch) source.Source
// Now and RetryEvery can be changed by tests. // Now and RetryEvery can be changed by tests.
Now func() time.Time Now func() time.Time
RetryEvery time.Duration RetryEvery time.Duration
@@ -61,18 +70,30 @@ type Daemon struct {
// Watch is one watch at work. // Watch is one watch at work.
type Watch struct { type Watch struct {
Cfg *config.Watch
Matcher *match.Matcher
Source source.Source Source source.Source
Tracker *bounty.Tracker Tracker *bounty.Tracker
// mu guards Cfg and Matcher, which a reload replaces.
mu sync.Mutex
Cfg *config.Watch
Matcher *match.Matcher
stop context.CancelFunc
done chan struct{}
lines, matches, bans atomic.Uint64 lines, matches, bans atomic.Uint64
} }
func (w *Watch) setup() (*config.Watch, *match.Matcher) {
w.mu.Lock()
defer w.mu.Unlock()
return w.Cfg, w.Matcher
}
// New makes a Daemon. hc may be a fake; sources come from the watches' // New makes a Daemon. hc may be a fake; sources come from the watches'
// configuration unless a test sets them (Watch.Source). // 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) { 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{}, d := &Daemon{cfg: cfg, log: log, hc: hc, logs: logs, 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{}, pending: map[string]state.Ban{}, wake: make(chan struct{}, 1), refused: map[string]time.Time{},
Now: time.Now, RetryEvery: 30 * time.Second} Now: time.Now, RetryEvery: 30 * time.Second}
for _, b := range st.Pending { for _, b := range st.Pending {
@@ -86,16 +107,7 @@ func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State,
if err != nil { if err != nil {
return nil, fmt.Errorf("watch %s: %v", w.Name, err) return nil, fmt.Errorf("watch %s: %v", w.Name, err)
} }
wt := &Watch{Cfg: w, Matcher: m, Tracker: bounty.New(w.Hits, w.Within.D())} d.watches = append(d.watches, &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 { if len(d.watches) == 0 {
return nil, errors.New("every watch is disabled: nothing to do") return nil, errors.New("every watch is disabled: nothing to do")
@@ -103,58 +115,113 @@ func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State,
return d, nil return d, nil
} }
// Watches are the watches at work (tests set their sources). // newSource makes a watch's source from its configuration.
func (d *Daemon) Watches() []*Watch { return d.watches } func (d *Daemon) newSource(w *config.Watch) source.Source {
if d.Sources != nil {
return d.Sources(w)
}
log := d.log.With("watch", w.Name)
switch w.Kind() {
case "journal":
return &source.Journal{Units: w.Journal.Unit, Identifiers: w.Journal.Identifier, Log: log}
case config.SourceHousecarlDrops:
return &source.HousecarlDrops{Client: d.logs, Protocol: w.Protocol, Shadow: w.Shadow, Log: log}
}
return &source.File{Pattern: w.File, Log: log}
}
// Watches are the watches at work (tests set their sources before Run).
func (d *Daemon) Watches() []*Watch {
d.mu.Lock()
defer d.mu.Unlock()
return append([]*Watch(nil), d.watches...)
}
func (d *Daemon) config() *config.Config {
d.mu.Lock()
defer d.mu.Unlock()
return d.cfg
}
func (d *Daemon) neverBan() *NeverBan {
d.mu.Lock()
defer d.mu.Unlock()
return d.never
}
// Run watches until ctx ends. // Run watches until ctx ends.
func (d *Daemon) Run(ctx context.Context) error { func (d *Daemon) Run(ctx context.Context) error {
d.started = d.Now() d.started = d.Now()
d.ctx = ctx
d.loadGroups(ctx) d.loadGroups(ctx)
go d.never.Run(ctx) d.mu.Lock()
var wg sync.WaitGroup d.startNever()
for _, w := range d.watches { for _, w := range d.watches {
lines := make(chan source.Line, 256) d.start(w)
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)
} }
d.mu.Unlock()
var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
d.sender(ctx) d.sender(ctx)
}() }()
if d.cfg.Socket != "" { if sock := d.config().Socket; sock != "" {
if err := d.serveStatus(ctx); err != nil { if err := d.serveStatus(ctx); err != nil {
d.log.Warn("no status socket (guard status won't work)", "socket", d.cfg.Socket, "err", err) d.log.Warn("no status socket (guard status won't work)", "socket", sock, "err", err)
} }
} }
<-ctx.Done() <-ctx.Done()
wg.Wait() wg.Wait()
for _, w := range d.Watches() {
<-w.done
}
d.savePending() d.savePending()
return nil return nil
} }
// startNever looks never_ban's names up until a reload replaces it
// (d.mu held).
func (d *Daemon) startNever() {
c, stop := context.WithCancel(d.ctx)
d.neverStop = stop
go d.never.Run(c)
}
// start runs a watch: its source, and its lines through Line, until
// stopped or Run's context ends (d.mu held).
func (d *Daemon) start(w *Watch) {
if w.Source == nil {
w.Source = d.newSource(w.Cfg)
}
ctx, stop := context.WithCancel(d.ctx)
w.stop, w.done = stop, make(chan struct{})
lines := make(chan source.Line, 256)
src := w.Source
go func() {
defer close(lines)
for {
err := src.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 close(w.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)
}
// Describe says where a watch's lines come from. // Describe says where a watch's lines come from.
func Describe(w *config.Watch) string { func Describe(w *config.Watch) string {
switch w.Kind() { switch w.Kind() {
@@ -173,12 +240,35 @@ func Describe(w *config.Watch) string {
return "file " + w.File return "file " + w.File
} }
// Short describes a watch's source in a table's few columns: at most
// two journal units or identifiers, then how many more.
func Short(w *config.Watch) string {
switch w.Kind() {
case "journal":
names := w.Journal.Unit
if len(names) == 0 {
names = w.Journal.Identifier
} else {
names = append(append([]string(nil), names...), w.Journal.Identifier...)
}
s := "journal " + strings.Join(names[:min(len(names), 2)], ", ")
if len(names) > 2 {
s += fmt.Sprintf(" +%d", len(names)-2)
}
return s
case config.SourceHousecarlDrops:
return "Housecarl drops (" + w.Protocol + ")"
}
return "file " + w.File
}
// loadGroups asks Housecarl how each ban list groups IPv6 addresses, so // 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 // bounties are counted as the list bans. A list that can't be read now
// counts by /64, its default. // counts by /64, its default.
func (d *Daemon) loadGroups(ctx context.Context) { func (d *Daemon) loadGroups(ctx context.Context) {
for _, w := range d.watches { for _, w := range d.Watches() {
list := w.Cfg.Ban.List cfg, _ := w.setup()
list := cfg.Ban.List
d.mu.Lock() d.mu.Lock()
_, done := d.groups[list] _, done := d.groups[list]
d.mu.Unlock() d.mu.Unlock()
@@ -216,33 +306,34 @@ func (d *Daemon) group(list string) int {
// Line handles one line of a watch. // Line handles one line of a watch.
func (d *Daemon) Line(ctx context.Context, w *Watch, l source.Line) { func (d *Daemon) Line(ctx context.Context, w *Watch, l source.Line) {
w.lines.Add(1) w.lines.Add(1)
cfg, m := w.setup()
addr := l.Addr addr := l.Addr
if !addr.IsValid() { if !addr.IsValid() {
var ok bool var ok bool
if addr, ok = w.Matcher.Match(l.Text); !ok { if addr, ok = m.Match(l.Text); !ok {
return return
} }
} }
w.matches.Add(1) w.matches.Add(1)
if why := d.never.Why(addr); why != "" { if why := d.neverBan().Why(addr); why != "" {
d.log.Debug("match, but never banned", "watch", w.Cfg.Name, "address", addr, "why", why) d.log.Debug("match, but never banned", "watch", cfg.Name, "address", addr, "why", why)
return return
} }
g := match.Group(addr, d.group(w.Cfg.Ban.List)) g := match.Group(addr, d.group(cfg.Ban.List))
n, reached := w.Tracker.Add(g, l.Time) 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)) d.log.Debug("match", "watch", cfg.Name, "address", addr, "bounty", n, "hits", cfg.Hits, "line", clip(l.Text, 300))
if !reached { if !reached {
return return
} }
w.bans.Add(1) w.bans.Add(1)
d.ban(w, addr, g, l.Text) d.ban(cfg, addr, g, l.Text)
} }
// BanTime is how long a group's next ban lasts: the watch's time, // 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 // times the repeat factor for each earlier ban within forget, up to the
// most. // most.
func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration { func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
r := d.cfg.Defaults.Repeat r := d.config().Defaults.Repeat
base := w.Ban.Time.D() base := w.Ban.Time.D()
n := d.state.Repeats(group, d.Now().Add(-r.Forget.D())) n := d.state.Repeats(group, d.Now().Add(-r.Forget.D()))
t := time.Duration(float64(base) * math.Pow(r.Factor, float64(n))) t := time.Duration(float64(base) * math.Pow(r.Factor, float64(n)))
@@ -255,12 +346,12 @@ func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
return t return t
} }
func (d *Daemon) ban(w *Watch, addr netip.Addr, g netip.Prefix, line string) { func (d *Daemon) ban(w *config.Watch, addr netip.Addr, g netip.Prefix, line string) {
group := g.String() group := g.String()
t := d.BanTime(w.Cfg, group) t := d.BanTime(w, group)
b := state.Ban{Time: d.Now(), Watch: w.Cfg.Name, List: w.Cfg.Ban.List, Address: addr.String(), Group: group, b := state.Ban{Time: d.Now(), Watch: w.Name, List: w.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)} Protocol: strings.ToLower(w.Ban.Protocol), Ports: strings.Join(w.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.log.Info("bounty reached; banning", "watch", w.Name, "address", addr, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(t))
d.mu.Lock() d.mu.Lock()
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
d.mu.Unlock() d.mu.Unlock()
@@ -368,10 +459,11 @@ func (d *Daemon) savePending() {
p = append(p, b) p = append(p, b)
} }
d.mu.Unlock() d.mu.Unlock()
cfg := d.config()
d.state.SetPending(p) d.state.SetPending(p)
d.state.Forget(d.Now().Add(-d.cfg.Defaults.Repeat.Forget.D())) d.state.Forget(d.Now().Add(-cfg.Defaults.Repeat.Forget.D()))
if err := d.state.Save(); err != nil { if err := d.state.Save(); err != nil {
d.log.Warn("saving the state failed", "file", d.cfg.State, "err", err) d.log.Warn("saving the state failed", "file", cfg.State, "err", err)
} }
} }
@@ -408,9 +500,10 @@ type WatchStatus struct {
// Status reports the watches, their highest bounties and the recent bans. // Status reports the watches, their highest bounties and the recent bans.
func (d *Daemon) Status() Status { func (d *Daemon) Status() Status {
s := Status{Started: d.started} s := Status{Started: d.started}
for _, w := range d.watches { 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, cfg, _ := w.setup()
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.Watches = append(s.Watches, WatchStatus{Name: cfg.Name, Source: Short(cfg), List: cfg.Ban.List, Hits: cfg.Hits,
Within: 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() s.Recent, s.Pending = d.state.Snapshot()
if len(s.Recent) > 50 { if len(s.Recent) > 50 {
@@ -422,7 +515,7 @@ func (d *Daemon) Status() Status {
// serveStatus answers GET /status on the local socket (root and the // serveStatus answers GET /status on the local socket (root and the
// guard user only: the socket is 0660 in /run/guard). // guard user only: the socket is 0660 in /run/guard).
func (d *Daemon) serveStatus(ctx context.Context) error { func (d *Daemon) serveStatus(ctx context.Context) error {
path := d.cfg.Socket path := d.config().Socket
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err return err
} }
@@ -437,6 +530,16 @@ func (d *Daemon) serveStatus(ctx context.Context) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(d.Status()) json.NewEncoder(w).Encode(d.Status())
}) })
mux.HandleFunc("POST /reload", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
res, err := d.ReloadFile()
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
json.NewEncoder(w).Encode(res)
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go srv.Serve(ln) go srv.Serve(ln)
go func() { go func() {
+137
View File
@@ -177,3 +177,140 @@ socket: ` + filepath.Join(dir, "guard.sock") + `
t.Errorf("saved state: %v %+v", err, st2.Offences) t.Errorf("saved state: %v %+v", err, st2.Offences)
} }
} }
// stoppable is a chanSource that says when it stopped.
type stoppable struct {
chanSource
stopped chan struct{}
}
func (s *stoppable) Run(ctx context.Context, out chan<- source.Line) error {
err := s.chanSource.Run(ctx, out)
close(s.stopped)
return err
}
// TestReload: a reload keeps the bounties of a watch under the same name
// and applies its new settings; a watch whose source changed gets a new
// one; watches gone stop, new ones start; settings for a restart are
// reported; a configuration with nothing to watch is refused.
func TestReload(t *testing.T) {
dir := t.TempDir()
base := `housecarl: {url: https://fw:8070, list: web}
defaults: {hits: 3, within: 10m, ban: 1h}
state: ` + filepath.Join(dir, "state.json") + `
socket: ` + filepath.Join(dir, "guard.sock") + `
`
parse := func(extra string) *config.Config {
t.Helper()
cfg, err := config.Parse([]byte(base + extra))
if err != nil {
t.Fatal(err)
}
return cfg
}
cfg := parse(`watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log}
- {name: web, file: /var/log/web.log, match: ['bad <IP>$']}
`)
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)
}
var mu sync.Mutex
srcs := map[string]*stoppable{}
d.Sources = func(w *config.Watch) source.Source {
mu.Lock()
defer mu.Unlock()
s := &stoppable{chanSource{make(chan source.Line)}, make(chan struct{})}
srcs[w.Name+" "+w.File] = s
return s
}
src := func(key string) *stoppable {
mu.Lock()
defer mu.Unlock()
return srcs[key]
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { d.Run(ctx); close(done) }()
waitFor(t, "the sources", func() bool { return src("ssh /var/log/auth.log") != nil && src("web /var/log/web.log") != nil })
fail := func(s *stoppable, addr string, n int) {
for i := 0; i < n; i++ {
s.ch <- source.Line{Text: "Failed password for root from " + addr + " port 4000 ssh2", Time: time.Now()}
}
}
ssh := src("ssh /var/log/auth.log")
fail(ssh, "203.0.113.9", 2)
res, err := d.Reload(parse(`never_ban: [198.51.100.0/24]
watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log, ban: {time: 2h}}
- {name: mail, preset: dovecot, file: /var/log/mail.log}
`))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(res.Changes, "; "); got != "never_ban; watch ssh: ban; watch mail: added; watch web: removed" || len(res.Restart) != 0 {
t.Errorf("reload: %q, restart %v", got, res.Restart)
}
select {
case <-src("web /var/log/web.log").stopped:
case <-time.After(2 * time.Second):
t.Error("the removed watch's source still runs")
}
if src("mail /var/log/mail.log") == nil {
t.Error("the added watch has no source")
}
// The bounty of 2 was kept: one more bans, for the new time.
fail(ssh, "203.0.113.9", 1)
waitFor(t, "the ban after the reload", func() bool { return hc.count() == 1 })
if b := hc.last(); b.Time < 7190 || b.Time > 7200 {
t.Errorf("ban after reload: %ds, want 2h", b.Time)
}
fail(ssh, "198.51.100.7", 3)
// A new file: a new source; the bounty stays; the hits change.
fail(ssh, "203.0.113.50", 2)
res, err = d.Reload(parse(`log: {level: debug}
watch:
- {name: ssh, preset: sshd, file: /var/log/secure, hits: 4, ban: {time: 2h}}
`))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(res.Changes, "; "); got != "never_ban; watch ssh: bounty 4 in 10m, source (started again); watch mail: removed" ||
strings.Join(res.Restart, ",") != "log" {
t.Errorf("reload: %q, restart %v", got, res.Restart)
}
select {
case <-ssh.stopped:
case <-time.After(2 * time.Second):
t.Error("the old source still runs")
}
secure := src("ssh /var/log/secure")
fail(secure, "203.0.113.50", 1)
time.Sleep(50 * time.Millisecond)
if hc.count() != 1 {
t.Fatal("banned below the new hits")
}
fail(secure, "203.0.113.50", 1)
waitFor(t, "the ban at 4", func() bool { return hc.count() == 2 && hc.last().Address == "203.0.113.50" })
if d.config().Log.Level != "info" {
t.Error("the log level changed without a restart")
}
// Nothing to watch: refused, the old configuration stays.
if _, err := d.Reload(parse(`watch:
- {name: ssh, preset: sshd, disabled: true}
`)); err == nil {
t.Error("a configuration with every watch disabled was taken")
}
if s := d.Status(); len(s.Watches) != 1 || s.Watches[0].Hits != 4 || s.Watches[0].Bans != 2 || s.Watches[0].Lines != 10 {
t.Errorf("status after reloads %+v", s.Watches)
}
cancel()
<-done
}
+185
View File
@@ -0,0 +1,185 @@
package daemon
import (
"errors"
"fmt"
"reflect"
"slices"
"strings"
"guard/internal/bounty"
"guard/internal/config"
"guard/internal/match"
)
// Reloaded is what a reload changed.
type Reloaded struct {
Changes []string `json:"changes"`
// Restart are the changed settings a reload can't apply: they wait
// for a restart.
Restart []string `json:"restart,omitempty"`
}
// ReloadFile reads the configuration again (Load) and reloads it.
func (d *Daemon) ReloadFile() (*Reloaded, error) {
if d.Load == nil {
return nil, errors.New("this Guard can't read its configuration again")
}
cfg, err := d.Load()
if err != nil {
return nil, err
}
return d.Reload(cfg)
}
// Reload puts a new configuration to work without losing what Guard
// counted: a watch keeps its bounties and counts under the same name,
// and its source runs on unless it changed. Watches gone stop, new ones
// start. The housecarl connection, state, socket and log wait for a
// restart. A configuration with no watch at work is refused, and the
// old one stays.
func (d *Daemon) Reload(cfg *config.Config) (*Reloaded, error) {
if d.ctx == nil {
return nil, errors.New("Guard isn't running")
}
type next struct {
cfg *config.Watch
m *match.Matcher
}
var want []next
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)
}
want = append(want, next{w, m})
}
if len(want) == 0 {
return nil, errors.New("every watch is disabled: nothing to do (the configuration stays as it was)")
}
res := &Reloaded{}
d.mu.Lock()
old := d.cfg
oh, nh := old.Housecarl, cfg.Housecarl
oh.List, nh.List = "", "" // the default list only fills in watches
if oh != nh {
res.Restart = append(res.Restart, "housecarl")
}
if old.State != cfg.State {
res.Restart = append(res.Restart, "state")
}
if old.Socket != cfg.Socket {
res.Restart = append(res.Restart, "socket")
}
if old.Log != cfg.Log {
res.Restart = append(res.Restart, "log")
}
cfg.Housecarl.URL, cfg.Housecarl.Socket, cfg.Housecarl.TokenFile, cfg.Housecarl.Token = old.Housecarl.URL, old.Housecarl.Socket, old.Housecarl.TokenFile, old.Housecarl.Token
cfg.Housecarl.CACert, cfg.Housecarl.Insecure = old.Housecarl.CACert, old.Housecarl.Insecure
cfg.State, cfg.Socket, cfg.Log = old.State, old.Socket, old.Log
if !slices.Equal(old.NeverBan, cfg.NeverBan) {
n := NewNeverBan(cfg.NeverBan, d.log)
n.Lookup = d.never.Lookup
d.never.mu.RLock()
for _, h := range n.names {
if a, ok := d.never.resolved[h]; ok {
n.resolved[h] = a
}
}
d.never.mu.RUnlock()
d.neverStop()
d.never = n
d.startNever()
res.Changes = append(res.Changes, "never_ban")
}
if old.Defaults.Repeat != cfg.Defaults.Repeat {
res.Changes = append(res.Changes, "defaults.repeat: for the next bans")
}
byName := map[string]*Watch{}
for _, w := range d.watches {
byName[strings.ToLower(w.Cfg.Name)] = w
}
var watches []*Watch
var stopped []chan struct{}
for _, n := range want {
w := byName[strings.ToLower(n.cfg.Name)]
delete(byName, strings.ToLower(n.cfg.Name))
if w == nil {
w = &Watch{Cfg: n.cfg, Matcher: n.m, Tracker: bounty.New(n.cfg.Hits, n.cfg.Within.D())}
d.start(w)
watches = append(watches, w)
res.Changes = append(res.Changes, "watch "+n.cfg.Name+": added")
continue
}
watches = append(watches, w)
oc, _ := w.setup()
if reflect.DeepEqual(oc, n.cfg) {
continue
}
var what []string
if !slices.Equal(oc.Match, n.cfg.Match) || !slices.Equal(oc.Ignore, n.cfg.Ignore) {
what = append(what, "patterns")
}
if oc.Hits != n.cfg.Hits || oc.Within != n.cfg.Within {
what = append(what, fmt.Sprintf("bounty %d in %s", n.cfg.Hits, n.cfg.Within))
}
if !reflect.DeepEqual(oc.Ban, n.cfg.Ban) {
what = append(what, "ban")
}
w.Tracker.Set(n.cfg.Hits, n.cfg.Within.D())
w.mu.Lock()
w.Cfg, w.Matcher = n.cfg, n.m
w.mu.Unlock()
if sourceKey(oc) != sourceKey(n.cfg) {
w.stop()
stopped = append(stopped, w.done)
w.Source = d.newSource(n.cfg)
d.start(w)
what = append(what, "source (started again)")
}
if len(what) == 0 {
what = append(what, "changed")
}
res.Changes = append(res.Changes, "watch "+n.cfg.Name+": "+strings.Join(what, ", "))
}
for _, w := range d.watches {
if byName[strings.ToLower(w.Cfg.Name)] == w {
w.stop()
stopped = append(stopped, w.done)
res.Changes = append(res.Changes, "watch "+w.Cfg.Name+": removed")
}
}
d.watches = watches
d.cfg = cfg
d.mu.Unlock()
// A stopped watch may be handling a line, which needs d.mu.
for _, done := range stopped {
<-done
}
d.loadGroups(d.ctx)
if len(res.Changes) == 0 {
res.Changes = []string{}
}
d.log.Info("reloaded", "changes", strings.Join(res.Changes, "; "))
if len(res.Restart) > 0 {
d.log.Warn("changed settings that wait for a restart", "settings", strings.Join(res.Restart, ", "))
}
return res, nil
}
// sourceKey is what a watch's source is made from: when it changes, the
// source starts again.
func sourceKey(w *config.Watch) string {
j := ""
if w.Journal != nil {
j = fmt.Sprint(w.Journal.Unit, w.Journal.Identifier)
}
return fmt.Sprint(w.Kind(), "|", w.File, "|", j, "|", w.Source, "|", w.Protocol, "|", w.Shadow)
}
+32 -5
View File
@@ -27,9 +27,10 @@ const Prefix = "/api/v1"
// Client talks to one Housecarl. // Client talks to one Housecarl.
type Client struct { type Client struct {
base string base string
token string socket string
http *http.Client token string
http *http.Client
} }
// Options say how to reach Housecarl. // Options say how to reach Housecarl.
@@ -72,7 +73,7 @@ func New(o Options) (*Client, error) {
} }
tr.TLSClientConfig = cfg tr.TLSClientConfig = cfg
} }
return &Client{base: base, token: strings.TrimSpace(o.Token), http: &http.Client{Transport: tr, Timeout: timeout}}, nil return &Client{base: base, socket: o.Socket, token: strings.TrimSpace(o.Token), http: &http.Client{Transport: tr, Timeout: timeout}}, nil
} }
// Error is an error answer from Housecarl. // Error is an error answer from Housecarl.
@@ -89,6 +90,32 @@ func (e *Error) Error() string {
return e.Message return e.Message
} }
// UnreachableError is a request Housecarl never answered: no
// connection, a TLS failure, a timeout.
type UnreachableError struct {
Where string
Err error
}
func (e *UnreachableError) Error() string {
err := e.Err
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err // without the method and the URL with its query
}
return "Housecarl at " + e.Where + " doesn't answer: " + err.Error()
}
func (e *UnreachableError) Unwrap() error { return e.Err }
// where is where the client reaches Housecarl, for messages.
func (c *Client) where() string {
if c.socket != "" {
return c.socket
}
return c.base
}
// Refused reports whether Housecarl refused a request for good: asking // Refused reports whether Housecarl refused a request for good: asking
// again won't help (a bad request, no permission, an address never // again won't help (a bad request, no permission, an address never
// banned). Anything else (no answer, a server error) is worth retrying. // banned). Anything else (no answer, a server error) is worth retrying.
@@ -123,7 +150,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
} }
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return err return &UnreachableError{Where: c.where(), Err: err}
} }
defer resp.Body.Close() defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
+2 -2
View File
@@ -142,10 +142,10 @@ not_contains "$NET2"
run "ssh from it still works" 0 connect 22 $NET2 run "ssh from it still works" 0 connect 22 $NET2
contains "^ssh$" contains "^ssh$"
run "guard test over the log" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml test ssh /w/auth.log run "guard test over the log" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml test ssh /w/auth.log
contains "would ban 1" contains "Would ban 1"
contains "never banned: in never_ban $NET2/32" contains "never banned: in never_ban $NET2/32"
run "guard unban" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml unban $NET run "guard unban" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml unban $NET
contains "1 bans lifted" contains "1 ban lifted"
run "ssh again" 0 connect 22 run "ssh again" 0 connect 22
contains "^ssh$" contains "^ssh$"
+3 -2
View File
@@ -114,7 +114,7 @@ done
files="$files dist/SHA256SUMS" files="$files dist/SHA256SUMS"
# The release (created, or found when publishing again). # The release (created, or found when publishing again).
release=$(call 404 "$api/releases/tags/$tag") release=$(call 404 "$api/releases/tags/$(printf '%s' "$tag" | sed 's/+/%2B/g')")
id=$(printf '%s\n' "$release" | jq -r '.id // empty') id=$(printf '%s\n' "$release" | jq -r '.id // empty')
if [ -z "$id" ]; then if [ -z "$id" ]; then
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \ body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
@@ -127,7 +127,8 @@ for f in $files; do
name=${f#dist/} name=${f#dist/}
old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id') old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id')
[ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null [ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$name" >/dev/null # A + (build metadata, 0.1.0-rc.1+git4-8fa62f9) would be a space in a query.
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$(printf '%s' "$name" | sed 's/+/%2B/g')" >/dev/null
echo "gitea-release: attached $name" echo "gitea-release: attached $name"
done done