feat(restic): alert to Telegram when either restic unit fails (#917) #921
No reviewers
Labels
No labels
burndown-2026-06
burndown-2026-08
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/advocate
role/director
role/exec
role/frontend
role/gamedev
role/human
role/platform
role/qa
role/science
role/sysadmin
state
ambient
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/infrastructure!921
Loading…
Reference in a new issue
No description provided.
Delete branch "ops/917-restic-failure-alert"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes what is left of #917 after the correction: the gap and the retention policy in that title were both already resolved, and failure surfacing was the real finding.
The gap this fills
Neither
restic-backup.servicenorrestic-prune.servicecarried anOnFailure=, and nothing else watched them. Verified by grep: there was noOnFailure=anywhere insystemd/before this, so this also sets the pattern for the next unit that needs one.A backup that silently does not run is worse than one that errors. The only reason that had not bitten is that it had not failed yet.
Shape
Both units now target
restic-failure-alert@%n.service.%nexpands to the failing unit's own name, so one templated handler serves both hosts and the alert names the unit that actually failed.The handler posts the unit, its
ResultandExecMainStatus, and the tail of its journal to the Telegram red channel.Three decisions worth stating:
kai-disk-pressure-watch, puts the token in the URL. That is one of the cases #848 tracks, so this deliberately does not copy it.o11y-telegramrole already stages the token into the login user'sfleet-o11ycache. Root reads it there. The directory differs per host, so it is a role default rather than something the script guesses.Installed from the shared part of the role, because
restic-backupfails onkai-serverandrestic-prunefails onser8.Verification
pre-commit run --all-filesgreen, shellcheck and ansible-lint included.just ansible-sync check fleet privileged tags=restic-backupreports exactly three changes on ser8, all expected:Nothing else moves.
kai-server needs an attended run
kai-server fails check mode with
Missing sudo password, which is environmental rather than anything about this change. ser8 has passwordless sudo and converges clean.So the rollout is split:
The second needs Kai at a prompt. Until it runs,
restic-pruneon ser8 is covered andrestic-backupon kai-server is not.Worth testing once after rollout with
systemd-runagainst a deliberately failing transient unit, or by confirming the next real failure alerts. An untested alerter is the same hypothesis an untested backup is.🤖 Generated with Claude Code
Automated review flagged a curl config injection in the first commit. It was right, and the impact was larger than injection: the alerter would have sent nothing at all.
curl parses a
-Kconfig line by line. The journal text was spliced in as a quoted value and journal output always contains newlines, so every send would have died at parse time:Before any request left the host. An alerter that silently does not alert is exactly the failure this PR exists to prevent, so this was a functional bug wearing a security bug's clothes.
The quote handling was a real injection vector on top of that. A journal line shaped like
url = "http://attacker/"would have been read as a curl option rather than message text.Fix
Only the token and chat id go through the stdin config now. Both are trusted and single-line. The journal text moves to argv, where curl treats it as opaque data and URL-encodes it. The token stays out of argv, which was the whole reason for the config.
Body is also capped at 3500 characters, since Telegram rejects a
sendMessageover 4096 and a crashing unit produces far more.Verified against the real script
Ran it against a local sink with a hostile
journalctlon PATH emittingurl = "http://evil.example/pwn":The POST goes to the real endpoint, the hostile line arrives as URL-encoded body text, and zero requests reached
evil.example. Same test on the pre-fix shape fails at the curl config parser without sending.The long explanation moved to
docs/restic-backups.mdto satisfy the comment-discipline cap, which is the right home for it anyway.