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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-25 01:32:20 +00:00
1 parent 9c655abae2
commit ecf4129877
11 files changed
+607 -93

No files matched your search

+86 -17
View File
@@ -53,6 +53,7 @@ keeps it from this machine (or from what the list says).
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 status the watches, their highest bounties, recent bans
guard reload read guard.yaml again, keeping the bounties
Exit codes: 0 ok, 1 error.`,
SilenceUsage: true,
@@ -61,7 +62,7 @@ Exit codes: 0 ok, 1 error.`,
}
root.PersistentFlags().StringVarP(&flagConfig, "config", "c", config.DefaultPath, "configuration file")
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 {
fmt.Fprintln(os.Stderr, "guard:", err)
os.Exit(1)
@@ -132,8 +133,19 @@ func runCmd() *cobra.Command {
if err != nil {
return err
}
d.Load = load
c, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
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)
return d.Run(c)
},
@@ -507,6 +519,32 @@ func clip(s string, n int) string {
// ---- 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 {
return &cobra.Command{
Use: "status", Short: "The running Guard: its watches, highest bounties, recent bans", Args: cobra.NoArgs,
@@ -515,22 +553,9 @@ func statusCmd() *cobra.Command {
if err != nil {
return err
}
hc := &http.Client{Timeout: 10 * 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)
}}}
resp, err := hc.Get("http://guard/status")
switch {
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)
resp, err := askDaemon(cfg, http.MethodGet, "/status")
if err != nil {
return err
}
defer resp.Body.Close()
var s daemon.Status
@@ -575,6 +600,50 @@ 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 {