Author SHA1 Message Date
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
9 changed files with 187 additions and 42 deletions

No files matched your search

+20
View File
@@ -28,3 +28,23 @@ All notable changes to Guard are documented here. The format follows
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".
+1 -1
View File
@@ -65,4 +65,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.1+git7-b87cc6f). Go, `CGO_ENABLED=0`, Linux. MIT licence.
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.1 0.1.0-rc.1+git7-b87cc6f
+100 -29
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"
@@ -161,7 +162,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 +170,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 +192,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 +279,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 +344,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 +359,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 +385,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 != "-" {
@@ -473,8 +520,17 @@ func statusCmd() *cobra.Command {
return d.DialContext(c, "unix", cfg.Socket) return d.DialContext(c, "unix", cfg.Socket)
}}} }}}
resp, err := hc.Get("http://guard/status") resp, err := hc.Get("http://guard/status")
if err != nil { switch {
return fmt.Errorf("Guard isn't running, or %s can't be reached (as root, or the guard user): %w", cfg.Socket, err) case errors.Is(err, os.ErrNotExist), errors.Is(err, syscall.ECONNREFUSED):
return fmt.Errorf("Guard isn't running (nothing answers on %s)", cfg.Socket)
case errors.Is(err, os.ErrPermission):
return 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 fmt.Errorf("Guard's socket %s: %w", cfg.Socket, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
var s daemon.Status var s daemon.Status
@@ -519,6 +575,17 @@ func statusCmd() *cobra.Command {
} }
} }
// 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 +629,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 +739,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
}, },
} }
+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
+23 -1
View File
@@ -173,6 +173,28 @@ 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.
@@ -409,7 +431,7 @@ type WatchStatus struct {
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, s.Watches = append(s.Watches, WatchStatus{Name: w.Cfg.Name, Source: Short(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())}) 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() s.Recent, s.Pending = d.state.Snapshot()
+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