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:
@@ -0,0 +1,75 @@
|
||||
// hooks_resolve.go — server-side resolution of pre/post hooks for a
|
||||
// backup dispatch (P2R-11). The agent receives plaintext hook bodies
|
||||
// in CommandRunPayload; this file is where the AEAD blob on the
|
||||
// source group (or the host's default) gets decrypted into the
|
||||
// strings the wire payload carries.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. source_group.<phase>_hook (per-group override)
|
||||
// 2. host.<phase>_hook_default (host-wide default)
|
||||
// 3. "" (no hook → agent skips that phase)
|
||||
//
|
||||
// Decrypt errors are logged and treated as "no hook configured" so
|
||||
// a malformed blob can't poison every backup. The audit trail
|
||||
// captures the underlying state regardless.
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// resolveBackupHooks returns the (pre, post) plaintext hook strings
|
||||
// the agent should run around the backup. Both are empty when no
|
||||
// hook is configured at either level.
|
||||
func (s *Server) resolveBackupHooks(host *store.Host, g *store.SourceGroup) (pre, post string) {
|
||||
if s.deps.AEAD == nil {
|
||||
return "", ""
|
||||
}
|
||||
pre = s.decryptHookOrFallback(g.PreHook, host.PreHookDefault, host.ID, "pre")
|
||||
post = s.decryptHookOrFallback(g.PostHook, host.PostHookDefault, host.ID, "post")
|
||||
return pre, post
|
||||
}
|
||||
|
||||
// decryptHookOrFallback returns the per-group hook decrypted, or
|
||||
// (when that's empty) the host default decrypted, or "" if neither
|
||||
// is configured. Decrypt failures log and degrade to empty.
|
||||
func (s *Server) decryptHookOrFallback(group, hostDefault, hostID, phase string) string {
|
||||
tryDecrypt := func(blob, slot string) (string, bool) {
|
||||
if blob == "" {
|
||||
return "", false
|
||||
}
|
||||
plain, err := s.deps.AEAD.Decrypt(blob, []byte("hook:"+hostID+":"+slot+":"+phase))
|
||||
if err != nil {
|
||||
slog.Error("decrypt hook", "host_id", hostID, "phase", phase, "slot", slot, "err", err)
|
||||
return "", false
|
||||
}
|
||||
return string(plain), true
|
||||
}
|
||||
if v, ok := tryDecrypt(group, "group"); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := tryDecrypt(hostDefault, "host"); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EncryptHookForGroup encrypts a hook body for storage on a source
|
||||
// group. Caller passes the plaintext from a UI form; an empty body
|
||||
// returns "" so the store persists NULL (cleared).
|
||||
func (s *Server) EncryptHookForGroup(hostID, phase, body string) (string, error) {
|
||||
if body == "" {
|
||||
return "", nil
|
||||
}
|
||||
return s.deps.AEAD.Encrypt([]byte(body), []byte("hook:"+hostID+":group:"+phase))
|
||||
}
|
||||
|
||||
// EncryptHookForHost is the host-default twin of EncryptHookForGroup.
|
||||
func (s *Server) EncryptHookForHost(hostID, phase, body string) (string, error) {
|
||||
if body == "" {
|
||||
return "", nil
|
||||
}
|
||||
return s.deps.AEAD.Encrypt([]byte(body), []byte("hook:"+hostID+":host:"+phase))
|
||||
}
|
||||
@@ -79,6 +79,13 @@ func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Reque
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve hooks (group → host default → empty). Best-effort host
|
||||
// lookup; failure proceeds with no hook rather than block the run.
|
||||
var preHook, postHook string
|
||||
if host, herr := s.deps.Store.GetHost(r.Context(), hostID); herr == nil {
|
||||
preHook, postHook = s.resolveBackupHooks(host, g)
|
||||
}
|
||||
|
||||
// Backup invocations don't consume RetentionPolicy — that lives on
|
||||
// forget. Sending the resolved set here would just be dead weight.
|
||||
res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobBackup,
|
||||
@@ -88,6 +95,8 @@ func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Reque
|
||||
Tag: g.Name,
|
||||
BandwidthUpKBps: upOverride,
|
||||
BandwidthDownKBps: downOverride,
|
||||
PreHook: preHook,
|
||||
PostHook: postHook,
|
||||
})
|
||||
if code != "" {
|
||||
s.runGroupError(w, r, status, code, msg)
|
||||
|
||||
@@ -192,6 +192,18 @@ func (s *Server) dispatchBackupForGroupCore(ctx context.Context, conn *ws.Conn,
|
||||
"schedule_id", scheduleID, "group", g.Name, "err", err)
|
||||
return "", err
|
||||
}
|
||||
// Resolve pre/post hooks (group → host default → empty) so they
|
||||
// ride on the backup payload as plaintext. The host lookup is
|
||||
// cheap; failure here is non-fatal (we proceed without hooks
|
||||
// rather than block the backup).
|
||||
var preHook, postHook string
|
||||
if host, herr := s.deps.Store.GetHost(ctx, hostID); herr == nil {
|
||||
preHook, postHook = s.resolveBackupHooks(host, g)
|
||||
} else {
|
||||
slog.Warn("schedule.fire: load host for hook resolve",
|
||||
"host_id", hostID, "err", herr)
|
||||
}
|
||||
|
||||
// Backup ignores RetentionPolicy — the forget cadence lives on
|
||||
// host_repo_maintenance and is driven by the server-side ticker
|
||||
// (P2R-06). Don't ship the field on backup dispatches.
|
||||
@@ -201,6 +213,8 @@ func (s *Server) dispatchBackupForGroupCore(ctx context.Context, conn *ws.Conn,
|
||||
Includes: g.Includes,
|
||||
Excludes: g.Excludes,
|
||||
Tag: g.Name,
|
||||
PreHook: preHook,
|
||||
PostHook: postHook,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("schedule.fire: marshal command.run",
|
||||
|
||||
Reference in New Issue
Block a user