agent+server: P2R-11 pre/post hook execution for backup jobs

Agent: new runner.BackupHooks struct + runHook helper invoked via
/bin/sh -c (cmd.exe /C on Windows). pre_hook non-zero exit aborts
the backup; post_hook always runs with RM_JOB_STATUS=succeeded|failed
in env. Output streamed as 'hook(<phase>): …' log.stream lines.
Hooks only run for kind=backup (other kinds skip both phases).

Server: resolveBackupHooks resolves group → host default → empty,
decrypts via crypto.AEAD with per-slot ad bytes, plumbs plaintext
into CommandRunPayload for both schedule.fire and per-group
Run-now dispatch sites. Decrypt failures degrade silently to no
hook so a malformed blob can't poison every backup.
This commit is contained in:
2026-05-04 10:57:28 +01:00
parent 18b0bf976d
commit 7b1990cf11
11 changed files with 379 additions and 52 deletions
+15 -15
View File
@@ -42,8 +42,8 @@ func TestSourceGroupHooksRoundTrip(t *testing.T) {
g := &SourceGroup{
ID: ulid.Make().String(), HostID: hostID, Name: "etc",
PreHook: []byte("ENC-PRE"),
PostHook: []byte("ENC-POST"),
PreHook: "ENC-PRE",
PostHook: "ENC-POST",
}
if err := st.CreateSourceGroup(context.Background(), g); err != nil {
t.Fatalf("create: %v", err)
@@ -52,16 +52,16 @@ func TestSourceGroupHooksRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("get: %v", err)
}
if string(got.PreHook) != "ENC-PRE" {
if got.PreHook != "ENC-PRE" {
t.Fatalf("PreHook: got %q, want ENC-PRE", got.PreHook)
}
if string(got.PostHook) != "ENC-POST" {
if got.PostHook != "ENC-POST" {
t.Fatalf("PostHook: got %q, want ENC-POST", got.PostHook)
}
// Update: clear PreHook, change PostHook.
got.PreHook = nil
got.PostHook = []byte("ENC-POST-2")
got.PreHook = ""
got.PostHook = "ENC-POST-2"
if err := st.UpdateSourceGroup(context.Background(), got); err != nil {
t.Fatalf("update: %v", err)
}
@@ -69,10 +69,10 @@ func TestSourceGroupHooksRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PreHook != nil {
t.Fatalf("PreHook: want nil after clear, got %q", got.PreHook)
if got.PreHook != "" {
t.Fatalf("PreHook: want empty after clear, got %q", got.PreHook)
}
if string(got.PostHook) != "ENC-POST-2" {
if got.PostHook != "ENC-POST-2" {
t.Fatalf("PostHook: got %q, want ENC-POST-2", got.PostHook)
}
}
@@ -82,25 +82,25 @@ func TestHostHookDefaultsRoundTrip(t *testing.T) {
st := newTestStore(t)
hostID := makeHostInStore(t, st, "host-hooks-host")
if err := st.SetHostHooks(context.Background(), hostID, []byte("PRE"), []byte("POST")); err != nil {
if err := st.SetHostHooks(context.Background(), hostID, "PRE", "POST"); err != nil {
t.Fatalf("set: %v", err)
}
h, err := st.GetHost(context.Background(), hostID)
if err != nil {
t.Fatalf("get: %v", err)
}
if string(h.PreHookDefault) != "PRE" || string(h.PostHookDefault) != "POST" {
if h.PreHookDefault != "PRE" || h.PostHookDefault != "POST" {
t.Fatalf("after set: pre=%q post=%q", h.PreHookDefault, h.PostHookDefault)
}
// Clear by passing nil.
if err := st.SetHostHooks(context.Background(), hostID, nil, nil); err != nil {
// Clear by passing empty strings.
if err := st.SetHostHooks(context.Background(), hostID, "", ""); err != nil {
t.Fatalf("clear: %v", err)
}
h, err = st.GetHost(context.Background(), hostID)
if err != nil {
t.Fatalf("get: %v", err)
}
if h.PreHookDefault != nil || h.PostHookDefault != nil {
t.Fatalf("after clear: pre=%v post=%v (want nil)", h.PreHookDefault, h.PostHookDefault)
if h.PreHookDefault != "" || h.PostHookDefault != "" {
t.Fatalf("after clear: pre=%q post=%q (want empty)", h.PreHookDefault, h.PostHookDefault)
}
}
+11 -7
View File
@@ -158,7 +158,7 @@ func scanHostRow(s hostScanner) (*Host, error) {
enrolled string
tags string
bwUp, bwDown sql.NullInt64
preHook, postHook []byte
preHook, postHook sql.NullString
)
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
@@ -215,18 +215,22 @@ func scanHostRow(s hostScanner) (*Host, error) {
v := int(bwDown.Int64)
h.BandwidthDownKBps = &v
}
h.PreHookDefault = preHook
h.PostHookDefault = postHook
if preHook.Valid {
h.PreHookDefault = preHook.String
}
if postHook.Valid {
h.PostHookDefault = postHook.String
}
return &h, nil
}
// SetHostHooks replaces the host-wide pre/post hook defaults. Pass
// nil/empty to clear that hook. Stored verbatim — caller is expected
// to encrypt the bytes before they reach this layer.
func (s *Store) SetHostHooks(ctx context.Context, hostID string, pre, post []byte) error {
// the empty string to clear that hook. Stored verbatim — caller is
// expected to encrypt before they reach this layer.
func (s *Store) SetHostHooks(ctx context.Context, hostID string, pre, post string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE hosts SET pre_hook_default = ?, post_hook_default = ? WHERE id = ?`,
nullableBytes(pre), nullableBytes(post), hostID)
nullableString(pre), nullableString(post), hostID)
if err != nil {
return fmt.Errorf("store: set host hooks: %w", err)
}
+9 -15
View File
@@ -52,7 +52,7 @@ func (st *Store) CreateSourceGroup(ctx context.Context, g *SourceGroup) error {
g.RetryMax, g.RetryBackoffSeconds,
nullableString(g.ConflictDimension),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
nullableBytes(g.PreHook), nullableBytes(g.PostHook),
nullableString(g.PreHook), nullableString(g.PostHook),
); err != nil {
return fmt.Errorf("store: create source group: %w", err)
}
@@ -96,7 +96,7 @@ func (st *Store) UpdateSourceGroup(ctx context.Context, g *SourceGroup) error {
g.RetryMax, g.RetryBackoffSeconds,
nullableString(g.ConflictDimension),
now.Format(time.RFC3339Nano),
nullableBytes(g.PreHook), nullableBytes(g.PostHook),
nullableString(g.PreHook), nullableString(g.PostHook),
g.ID, g.HostID,
)
if err != nil {
@@ -226,7 +226,7 @@ func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
includes, excludes, retention string
conflict sql.NullString
createdAt, updatedAt string
preHook, postHook []byte
preHook, postHook sql.NullString
)
err := s.Scan(&out.ID, &out.HostID, &out.Name,
&includes, &excludes, &retention,
@@ -235,8 +235,12 @@ func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
if err != nil {
return nil, err
}
out.PreHook = preHook
out.PostHook = postHook
if preHook.Valid {
out.PreHook = preHook.String
}
if postHook.Valid {
out.PostHook = postHook.String
}
if includes != "" {
_ = json.Unmarshal([]byte(includes), &out.Includes)
}
@@ -264,13 +268,3 @@ func nullableString(s string) any {
}
return s
}
// nullableBytes returns nil for an empty/nil slice so SQL stores it
// as NULL rather than an empty BLOB. The agent treats both the same
// (no hook), but NULL is the canonical "absent" form on disk.
func nullableBytes(b []byte) any {
if len(b) == 0 {
return nil
}
return b
}
+12 -11
View File
@@ -67,11 +67,12 @@ type Host struct {
BandwidthUpKBps *int
BandwidthDownKBps *int
// PreHookDefault / PostHookDefault are AEAD-encrypted host-wide
// hook bodies. Per source group hooks (SourceGroup.PreHook /
// PostHook) override these when set. nil = no default configured.
PreHookDefault []byte
PostHookDefault []byte
// PreHookDefault / PostHookDefault are AEAD ciphertext (string
// blob produced by crypto.AEAD.Encrypt). Per source group hooks
// (SourceGroup.PreHook / PostHook) override these when set.
// Empty = no default configured.
PreHookDefault string
PostHookDefault string
}
// Schedule is now intentionally slim: cron + which groups + enabled.
@@ -113,12 +114,12 @@ type SourceGroup struct {
CreatedAt time.Time
UpdatedAt time.Time
// PreHook / PostHook are AEAD-encrypted shell snippets (raw blob).
// nil means "no hook configured." Encryption/decryption happens at
// the HTTP layer (where AEAD lives); the store layer just persists
// the bytes verbatim.
PreHook []byte
PostHook []byte
// PreHook / PostHook are AEAD ciphertext (string blob produced by
// crypto.AEAD.Encrypt). Empty means "no hook configured."
// Encryption/decryption happens at the HTTP layer (where AEAD
// lives); the store layer just persists the bytes verbatim.
PreHook string
PostHook string
}
// RetentionPolicy is the typed view of `restic forget --keep-*`.