// 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._hook (per-group override) // 2. host._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)) }