PUT source-group silently discards pre_hook/post_hook — hooks are settable only via the UI #54

Closed
opened 2026-08-22 13:19:06 +01:00 by bobby · 0 comments

Summary

PUT /api/hosts/{id}/source-groups/{gid} accepts a body containing pre_hook / post_hook, returns 200 with the updated group, and silently discards the hook. Nothing is persisted and no error or warning is returned. The only way to configure hooks is the web UI.

The silent part is the bug. An API client has no way to tell the difference between "hook configured" and "hook ignored" — the 200 response looks like success, and because the response body also omits hook fields, a read-back cannot detect the discrepancy either.

Reproduce

curl -X PUT -H 'Content-Type: application/json' \
  -d '{"name":"default","includes":["/srv"],"excludes":[],
       "retention_policy":{"keep_daily":7},"retry_max":3,
       "retry_backoff_seconds":60,
       "pre_hook":"/path/to/script.sh"}' \
  https://<server>/api/hosts/<host-id>/source-groups/<group-id>

Returns 200 with the group JSON. updated_at advances. The hook is not stored — verified by running a backup afterwards and observing no hook(pre): lines in the job log and no side effects from the script.

Cause

internal/server/http/source_groups.go has no hook fields in either the response view or the request struct:

type sourceGroupView struct {
    ID, HostID, Name string
    Includes, Excludes []string
    RetentionPolicy store.RetentionPolicy
    RetryMax, RetryBackoffSeconds int
    ConflictDimension string
    CreatedAt, UpdatedAt time.Time
}
// request struct likewise has no PreHook/PostHook

Meanwhile the persistence and UI layers fully support them — internal/store/sources.go reads and writes pre_hook/post_hook columns, and internal/server/http/ui_sources.go handles them including EncryptHookForGroup. So the capability exists everywhere except the JSON API.

Unknown JSON fields are ignored by default in Go, which is why the field vanishes without complaint.

Impact

Medium. Hooks are the supported mechanism for database quiescing, and quiescing is exactly the kind of thing an operator wants to configure reproducibly rather than by hand in a browser. Today that configuration cannot be scripted, version-controlled, or applied to several hosts programmatically — and an attempt to do so fails silently.

It also blocks automating a known-good configuration across a fleet: every host with a database needs the same hook, and each one has to be typed into the UI individually.

Suggested fix

Either:

  1. Support hooks in the API — add pre_hook/post_hook to the request struct, encrypt on write exactly as the UI path does, and return a redacted indicator (e.g. has_pre_hook: true) rather than the plaintext body, so secrets are not exposed on GET while clients can still verify configuration landed.

  2. Or reject them explicitly — return 400 unknown_field when a hook key is present, so the failure is loud. This is much less useful but strictly better than silence.

Option 1 is preferable. If hook bodies must stay UI-only for secret-handling reasons, a boolean indicator on GET would still let a client detect "hook not configured" without exposing the script.

Environment

  • Server v1.2.1, agent v1.2.1
  • Confirmed against internal/server/http/source_groups.go, internal/store/sources.go, internal/server/http/ui_sources.go
## Summary `PUT /api/hosts/{id}/source-groups/{gid}` accepts a body containing `pre_hook` / `post_hook`, returns **200 with the updated group**, and **silently discards the hook**. Nothing is persisted and no error or warning is returned. The only way to configure hooks is the web UI. The silent part is the bug. An API client has no way to tell the difference between "hook configured" and "hook ignored" — the 200 response looks like success, and because the response body also omits hook fields, a read-back cannot detect the discrepancy either. ## Reproduce ```bash curl -X PUT -H 'Content-Type: application/json' \ -d '{"name":"default","includes":["/srv"],"excludes":[], "retention_policy":{"keep_daily":7},"retry_max":3, "retry_backoff_seconds":60, "pre_hook":"/path/to/script.sh"}' \ https://<server>/api/hosts/<host-id>/source-groups/<group-id> ``` Returns `200` with the group JSON. `updated_at` advances. The hook is not stored — verified by running a backup afterwards and observing no `hook(pre):` lines in the job log and no side effects from the script. ## Cause `internal/server/http/source_groups.go` has no hook fields in either the response view or the request struct: ```go type sourceGroupView struct { ID, HostID, Name string Includes, Excludes []string RetentionPolicy store.RetentionPolicy RetryMax, RetryBackoffSeconds int ConflictDimension string CreatedAt, UpdatedAt time.Time } // request struct likewise has no PreHook/PostHook ``` Meanwhile the persistence and UI layers fully support them — `internal/store/sources.go` reads and writes `pre_hook`/`post_hook` columns, and `internal/server/http/ui_sources.go` handles them including `EncryptHookForGroup`. So the capability exists everywhere except the JSON API. Unknown JSON fields are ignored by default in Go, which is why the field vanishes without complaint. ## Impact Medium. Hooks are the supported mechanism for database quiescing, and quiescing is exactly the kind of thing an operator wants to configure reproducibly rather than by hand in a browser. Today that configuration cannot be scripted, version-controlled, or applied to several hosts programmatically — and an attempt to do so fails silently. It also blocks automating a known-good configuration across a fleet: every host with a database needs the same hook, and each one has to be typed into the UI individually. ## Suggested fix Either: 1. **Support hooks in the API** — add `pre_hook`/`post_hook` to the request struct, encrypt on write exactly as the UI path does, and return a redacted indicator (e.g. `has_pre_hook: true`) rather than the plaintext body, so secrets are not exposed on GET while clients can still verify configuration landed. 2. **Or reject them explicitly** — return `400 unknown_field` when a hook key is present, so the failure is loud. This is much less useful but strictly better than silence. Option 1 is preferable. If hook bodies must stay UI-only for secret-handling reasons, a boolean indicator on GET would still let a client detect "hook not configured" without exposing the script. ## Environment - Server `v1.2.1`, agent `v1.2.1` - Confirmed against `internal/server/http/source_groups.go`, `internal/store/sources.go`, `internal/server/http/ui_sources.go`
steve closed this issue 2026-08-22 13:45:04 +01:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/restic-manager#54