feat(ntfy): alert on any systemd service failure#408
Conversation
etrobert-bot
left a comment
There was a problem hiding this comment.
Reviewed with independent verification of the load-bearing systemd claims (against systemd 260 source in ~/.cache/explore/systemd, systemd.unit(5), and live tests on a running systemd 260 manager). Verdict: the mechanism is sound and the PR's claims all check out — no blocking findings. 1 suggestion and 2 questions below, plus two praise comments documenting the verification so it doesn't have to be redone.
What I verified independently:
- Loop-prevention masking — confirmed in docs, source, and live (see inline praise on the mask).
- Templated-service edge case — a failing
dummy@tty1.service-style unit produces the double-@instancentfy-failure@dummy@tty1.service.service; it is a valid unit name, loads, is masked (emptyOnFailure), activates on failure, and%ihands the script the exact failing unit name (see inline praise on the%nline). - Built tower toplevel from the branch (
nix build .#nixosConfigurations.tower...toplevel):/etc/systemd/system/service.d/10-ntfy-failure.confand the mask are both in the generated tree; the renderedntfy-failure@.servicehas curl + coreutils (tail) + systemd (journalctl) on its unit PATH and noUser=, so it runs as root and can read the system journal. nix flake check --accept-flake-config: pass; pi and leod toplevels evaluate.- On-tower delivery is DNS-independent: the built toplevel's
/etc/hostsmapstower→127.0.0.2and ntfy listens on:2586, so tower's own alerts don't depend on pi's dnsmasq (no chicken-and-egg). - The re-nesting of
ntfy/ntfyDesktopundernixosModules = { ... }is indentation-only — bodies are semantically identical,darwinModules.ntfyDesktopuntouched. - Conventions: long-form flags throughout,
/* bash */language hint present, comments justified.
praise: Clean single-source design — the alert template reuses the same url/topic let-bindings as the server module, which is exactly why colocating this in modules/ntfy.nix was right.
(Reviewing as the PR author's account, so submitting as COMMENT rather than approve.)
| (pkgs.linkFarm "ntfy-failure-alert-dropins" { | ||
| "etc/systemd/system/service.d/10-ntfy-failure.conf" = pkgs.writeText "10-ntfy-failure.conf" '' | ||
| [Unit] | ||
| OnFailure=ntfy-failure@%n.service |
There was a problem hiding this comment.
praise: Verified the %n → instance round-trip independently, including the templated-failing-service edge case. Every valid service name is also a valid instance string: the instance charset is a superset of the unit-name charset and explicitly permits @ (src/basic/unit-name.c, unit_instance_is_valid: "We allow additional @ in the instance string"), and unit_name_is_valid splits at the first @ — so no systemd-escaping is needed here, even for \x2d-escaped names. Live test on systemd 260 (user manager, same drop-in/instantiation code): a failing dummy@tty1.service-style instance produced OnFailure=ntfy-failure@dummy@tty1.service.service; that double-@ name loaded and activated on failure, and %i handed the script back dummy@tty1.service verbatim — exactly what journalctl --unit needs.
There was a problem hiding this comment.
Thanks for verifying the double-@ instance edge case independently — nothing to add.
| # and a failed alert (e.g. tower unreachable) cannot trigger | ||
| # itself. | ||
| "etc/systemd/system/ntfy-failure@.service.d/10-ntfy-failure.conf" = | ||
| pkgs.writeText "10-ntfy-failure-mask.conf" '' |
There was a problem hiding this comment.
praise: The same-name masking trick checks out against both docs and source, including the NixOS case where both files live in the same /etc/systemd/system tree. systemd.unit(5): "Files in type.d/ have lower precedence compared to files in name-specific override directories. … a file in type.d/ applies to a unit only if there are no drop-ins or masks with that name in directories with higher precedence." Implementation: unit_file_find_dropin_paths appends the service.d dirs last (src/shared/dropin.c: "Add this last as it's the most generic and should be able to be overridden by more specific drop-ins") and the file list is deduped by basename with earlier dirs winning (src/basic/conf-files.c, files_add: "Has this file already been found in an earlier directory?") — so template-dir beats type-dir regardless of which root they sit in. Confirmed in the built tower toplevel (both drop-ins present) and live on systemd 260: the alert instance shows OnFailure= empty with only the mask in DropInPaths.
There was a problem hiding this comment.
Thanks for the independent docs+source+live confirmation of the same-tree masking case — nothing to add.
| script = /* bash */ '' | ||
| journalctl --unit "$1" --lines 15 --no-pager | | ||
| tail --bytes 4000 | | ||
| curl --silent --show-error --max-time 10 \ |
There was a problem hiding this comment.
suggestion (non-blocking): Consider adding --fail. Without it curl exits 0 on HTTP 4xx/5xx (e.g. ntfy replying 429/503), the oneshot is marked successful, and the undelivered alert leaves no trace anywhere. With --fail, an HTTP-level delivery failure behaves like a connection-level one already does — the instance lands in systemctl --failed as a local trace, and the mask keeps it loop-safe. (A modest --retry N --retry-connrefused to ride out brief outages could be a follow-up, but plain --fail is the cheap win.)
There was a problem hiding this comment.
Applied in 7d5aec0: curl --fail added, so an HTTP 4xx/5xx now lands the instance in systemctl --failed like a connection-level error already did (loop-safe via the mask). Kept --silent --show-error as-is — with --fail curl still prints curl: (22) The requested URL returned error: <code> to stderr, so the alert instance's journal records why. Left --retry out per minimal-v1; happy to follow up if lost alerts turn out to matter in practice.
| networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ port ]; | ||
| }; | ||
| # Alert on any systemd service failure: a top-level drop-in (service.d/) | ||
| # attaches OnFailure=ntfy-failure@<unit>.service to every system service, |
There was a problem hiding this comment.
question: Is unbounded alert volume acceptable for v1? OnFailure= fires on every transition into failed, so a timer-driven oneshot that fails on each tick alerts on every run (a 5-minute timer → ~288/day) — and per the APNs comment earlier in this file, the ntfy.sh relay free tier is ~250 msg/day, so a single flapping unit could exhaust iPhone delivery for the day in addition to spamming the topic. Probably fine across three quiet hosts; if intended, a sentence here acknowledging "no rate limiting, by design" would make it a documented decision rather than a surprise.
There was a problem hiding this comment.
Yes — unbounded by design for v1, now stated in the module comment (7d5aec0: "Delivery is best-effort — no rate limiting and no retry").
I did evaluate the obvious minimal mechanism, StartLimitIntervalSec=/StartLimitBurst= on the template ([Unit] rate limiting, per-instance counters — which would be exactly the right granularity for a flapping unit), and rejected it because it doesn't actually work for the flapping-timer case: the start-limit counter lives on the unit object, and a templated instance that completes successfully goes inactive and is garbage-collected (unit_may_gc, src/core/unit.c — default CollectMode=inactive collects any inactive unit). Each new OnFailure trigger then loads a fresh instance with a fresh counter, so the limit would only ever throttle alert instances that are themselves failing (those stay loaded in failed state) — i.e. it bounds nothing while delivery succeeds, which is precisely the quota-exhaustion scenario. Anything that does work (persistent state, a dedup daemon) is out of proportion for three quiet personal hosts.
On the quota itself: only the iPhone APNs relay (~250 msg/day free tier) can be exhausted; the self-hosted topic and desktop subscribers are unaffected, so a flapper would still be loudly visible — arguably the alert doing its job.
| curl --silent --show-error --max-time 10 \ | ||
| --header "Title: $1 failed on ${config.networking.hostName}" \ | ||
| --data-binary @- \ | ||
| "${url}/${topic}" |
There was a problem hiding this comment.
question: Alerts fired while the delivery path is down are lost for good — accepted as a known limitation? From pi/leod, delivery needs tailscaled up on both ends: tower's 2586 is opened on tailscale0 only, and tower must resolve via MagicDNS to the Tailscale IP (pi's dnsmasq has no tower host-record, and the LAN IP would be firewalled anyway). So early-boot failures on pi/leod, or anything failing while tower is down, produce a failed ntfy-failure@… instance (visible in systemctl --failed, which is a genuinely useful local trace) but never deliver, and nothing retries later. Tower itself is safe even with DNS down (/etc/hosts → 127.0.0.2, verified in the built toplevel). If this is fine for v1 — I think it is — a short note in the module comment would close the loop.
There was a problem hiding this comment.
Accepted as a known limitation for v1, yes — and thanks for verifying the tower-side /etc/hosts independence. An alert that can't reach tower (early boot on pi/leod, tailscaled down on either end, tower itself down) fails once and nothing retries; the failed ntfy-failure@… instance in systemctl --failed is the local trace. Rather than a note about delivery-path topology (which would go stale with any tailscale/firewall change), I folded this into the same one-line contract in the module comment as the rate-limiting answer (7d5aec0): "Delivery is best-effort — no rate limiting and no retry; an undelivered alert only leaves a failed ntfy-failure@ instance (systemctl --failed)." If lost boot-time alerts turn out to matter, a --retry/queueing follow-up can revisit it.
26982d2 to
7d5aec0
Compare
|
Review addressed in 7d5aec0 (branch rebased onto current main — wlsunset merge):
Re-verified on the rebased tree: |
etrobert-bot
left a comment
There was a problem hiding this comment.
Round 2, reviewing 7d5aec0 (rebased onto 727b0db). Scope: the delta since round 1 plus adjudication of the declined rate-limit suggestion — round-1 verification not redone since unit semantics are unchanged by the rebase.
- Rebase: clean —
git diff 727b0db3 7d5aec08touches exactly the 4 PR files; range-diff shows the original commit unchanged plus one follow-up commit. --failapplied: confirmed in the rebuilt tower closure — the renderedntfy-failure@start script now readscurl --fail --silent --show-error --max-time 10. Keeping--silent --show-erroris right:--failstill emitscurl: (22) …on stderr, so the failed instance's journal records the HTTP code. Fine to leave--retryout for v1.- Rate-limit decline: accepted — the GC reasoning is correct (verified against the source, see inline note on the new comment line).
- New best-effort comment line: meets the repo convention — states only current behavior, one non-obvious operational contract, two lines, no topology details that would go stale.
- All five round-1 threads have substantive replies; nothing left open from my side.
Verdict: Approved
| # Alert on any systemd service failure: a top-level drop-in (service.d/) | ||
| # attaches OnFailure=ntfy-failure@<unit>.service to every system service, | ||
| # and the template posts the failing unit's recent journal to the topic. | ||
| # Delivery is best-effort — no rate limiting and no retry; an undelivered |
There was a problem hiding this comment.
note: I checked the StartLimit-can't-throttle-delivered-alerts claim against the systemd source before accepting the decline — it holds. The start-rate counter is per-Unit-object state (u->start_ratelimit, initialized in unit_new, src/core/unit.c:122; checked via ratelimit_below in unit_test_start_limit, unit.c:1872). After a successful oneshot run the instance is inactive, so under the default CollectMode=inactive unit_may_gc allows collection (unit.c:461-465), and nothing pins it in unit_gc_sweep: only UNIT_ATOM_REFERENCED_BY deps or explicit refs keep a unit GOOD (src/core/manager.c:1391-1409), and UNIT_ON_FAILURE / UNIT_ON_FAILURE_OF map to exactly their own single atoms — no REFERENCES/REFERENCED_BY (src/core/unit-dependency-atom.c:79,81). So a delivered alert instance is freed, and the next failure re-instantiates it with a fresh counter — StartLimitBurst= could only ever throttle instances that stay loaded, i.e. already-failing deliveries, as you said. The comment line you added is the right resolution: true-now, minimal, and documents the one non-obvious contract.
| script = /* bash */ '' | ||
| journalctl --unit "$1" --lines 15 --no-pager | | ||
| tail --bytes 4000 | | ||
| curl --fail --silent --show-error --max-time 10 \ |
There was a problem hiding this comment.
praise: Verified in the rebuilt tower closure — the rendered start script contains curl --fail --silent --show-error --max-time 10, and --show-error still surfaces curl: (22) The requested URL returned error: … in the failed instance's journal, so the local trace names the HTTP code. Exactly the cheap win intended; agreed on deferring --retry.
| self.nixosModules.base | ||
| self.nixosModules.unfree | ||
| self.nixosModules.hypridle | ||
| self.nixosModules.ntfyFailureAlerts |
There was a problem hiding this comment.
If something needs to be added to leod/pi/tower probably the right place is to add in nixos-base.nix
There was a problem hiding this comment.
Applied in 6462b86b: ntfyFailureAlerts is now imported once in modules/nixos-base.nix (imported by all three NixOS hosts) and dropped from the per-host leod/pi/tower module lists. aaron (darwin) does not import nixos-base, so it stays untouched.
| script = /* bash */ '' | ||
| journalctl --unit "$1" --lines 15 --no-pager | | ||
| tail --bytes 4000 | | ||
| curl --fail --silent --show-error --max-time 10 \ |
There was a problem hiding this comment.
suggestion: Should we use the ntfy-wrapped instead to not have to specify url not topic?
There was a problem hiding this comment.
Applied in b5f0939f: the alert now pipes the journal into ntfy publish --quiet --title … from ntfy-wrapped instead of curl, so the endpoint (NTFY_TOPIC=http://tower:2586/home) is no longer named here — the interface is just a title plus the stdin body.
Verified against a mock server that stdin becomes the message body, --title sets the X-Title header, NTFY_TOPIC supplies the topic, and the CLI exits non-zero on an HTTP 4xx/5xx (so a failed delivery still lands the instance in systemctl --failed, exactly as curl --fail did) while --quiet stays silent on success but still prints the server error on failure. One live publish to tower's ntfy returned exit 0. Also added serviceConfig.TimeoutStartSec = "15s" to replace curl's --max-time 10, bounding a hung delivery (loop-safe — the mask empties OnFailure regardless of how the alert fails).
Add nixosModules.ntfyFailureAlerts (imported by tower, leod, pi): a top-level service.d/ drop-in attaches OnFailure=ntfy-failure@%n.service to every system service, and the ntfy-failure@ oneshot template posts the failing unit's last journal lines to the ntfy topic, titled "<unit> failed on <host>". Mechanism notes, verified against systemd source and systemd.unit(5): - systemd has no DefaultOnFailure= manager setting, so a type-level (service.d/) drop-in is the only global hook; it ships via systemd.packages because /etc/systemd/system is a generated symlink tree with no NixOS option for type-level drop-ins. - OnFailure= cannot be reset to an empty list in a later drop-in (dependencies are append-only), so loop prevention instead uses same-name masking: an identically named drop-in under ntfy-failure@.service.d/ takes precedence over the service.d/ one, leaving alert instances without OnFailure. A failed alert (e.g. tower unreachable) therefore cannot trigger itself. Merge and mask semantics verified empirically against systemd 260 via the user manager: the drop-in expands %n and triggers the template on a dummy failure, and the alert instance shows OnFailure= empty. Closes #407 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add curl --fail so an HTTP 4xx/5xx from ntfy marks the alert instance failed (visible in systemctl --failed) instead of silently succeeding, and document that delivery is best-effort: no rate limiting, no retry. A StartLimitIntervalSec/Burst rate limit was considered and rejected: the counter lives on the unit object, and a successfully-completed instance is garbage-collected (unit_may_gc, CollectMode=inactive), so the limit would only throttle alerts that already fail to deliver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7d5aec0 to
e518bbd
Compare
The alerts apply to every NixOS host, so import the module once in nixos-base.nix instead of repeating it in leod/pi/tower. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e518bbd to
6d18770
Compare
| serviceConfig = { | ||
| Type = "oneshot"; | ||
| # Bound a hung delivery when the ntfy server is unreachable. | ||
| TimeoutStartSec = "15s"; |
There was a problem hiding this comment.
question: What would happen without this line?
There was a problem hiding this comment.
Less than you might expect — it is not the line between bounded and unbounded. Type=oneshot is still subject to systemd's start timeout, which falls back to the system manager's DefaultTimeoutStartSec. We don't override that (nixos-base only sets DefaultTimeoutStopSec=15s, and only on the user manager), so the built tower's system.conf carries the upstream default of 90s. Without this line a hung delivery would just sit for up to 90s before systemd SIGTERMs it and marks the instance failed, instead of 15s.
Whether a hang is even possible: ntfy publish uses Go's http.DefaultClient (client/client.go:115), which has no client-level timeout; only http.DefaultTransport bounds the dial at 30s. So:
- connection refused / DNS failure → errors immediately (well under 15s either way);
- blackholed host (no SYN-ACK) → dial errors at ~30s without the line, killed at 15s with it;
- host that accepts the TCP connection but then stalls (e.g. tower up but ntfy wedged) → no app-level timeout, so it hangs until systemd's 90s without the line, 15s with it.
So the line only tightens the worst-case cleanup from 90s to 15s (and is what replaced curl's --max-time 10). It's defensive, not correctness-critical — I'm happy to drop it and rely on the 90s default if you'd rather keep the service config minimal.
There was a problem hiding this comment.
Dropped — serviceConfig is back to just Type = "oneshot", so a hung delivery now falls back to systemd's 90s DefaultTimeoutStartSec. Folded into the ntfy-wrapped commit (29d7e52); tower still builds.
Replace the hand-rolled curl call with `ntfy publish` from ntfy-wrapped, which already carries the endpoint (NTFY_TOPIC) — so the topic URL is no longer named here. --quiet stays silent on success but prints server errors, and the CLI exits non-zero on an HTTP error, so a failed delivery still lands the instance in `systemctl --failed` (as curl --fail did). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6d18770 to
29d7e52
Compare
The systemd-failure alerts are a feature built on top of the ntfy transport, not part of it, so move ntfyFailureAlerts out of ntfy.nix into modules/ntfy-failure-alerts.nix. Pure code move — no behavior change (the rendered units are byte-identical). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f238152 to
9f611eb
Compare
Closes #407
What
When any systemd service fails on a NixOS host (tower, leod, pi), push a notification to the self-hosted ntfy topic: title
<unit> failed on <host>, body = the unit's last 15 journal lines (capped at 4000 bytes, under ntfy's 4096-byte message limit). Newflake.nixosModules.ntfyFailureAlertslives inmodules/ntfy.nixnext to the server/subscriber modules and is imported once bymodules/nixos-base.nix, so every NixOS host gets it without a per-host import. aaron (darwin) doesn't import nixos-base, so it stays untouched.The alert publishes with
ntfy publish --quiet --title …fromntfy-wrapped(which carriesNTFY_TOPIC=http://tower:2586/home), so the endpoint isn't named here;serviceConfig.TimeoutStartSec = "15s"bounds a hung delivery.--quietis silent on success but still prints server errors, and the CLI exits non-zero on an HTTP 4xx/5xx — so a failed delivery lands the instance insystemctl --failed, the loop-safe local trace.Approach
Two candidate mechanisms were researched against the systemd source (
~/.cache/explore/systemd) and systemd.unit(5):DefaultOnFailure=manager setting: does not exist. No hits insrc/core/orman/— the global drop-in is the only mechanism.service.d/10-ntfy-failure.confwithOnFailure=ntfy-failure@%n.serviceapplies to every system service (specifiers expand in dependency directives —config_parse_unit_depscallsunit_name_printf, src/core/load-fragment.c). Shipped viasystemd.packagesbecause/etc/systemd/systemis a generated symlink tree and NixOS has no option for type-level drop-ins (generateUnitssymlinks package drop-in dirs, nixos/lib/systemd-lib.nix).Loop prevention
The task-book approach — a later-sorting drop-in resetting
OnFailure=— does not work: systemd.unit(5) states "Dependencies (After=, etc.) cannot be reset to an empty list". Instead, an identically named drop-in in the more specific directory (ntfy-failure@.service.d/10-ntfy-failure.conf, containing only[Unit]) masks theservice.d/one, so alert instances never getOnFailureat all — a failed alert (e.g. tower unreachable) cannot trigger another alert, regardless of how it fails (exit code, timeout, OOM).Verification
nix fmt,nix flake check --accept-flake-config(statix/deadnix/yamllint/stylua): pass.nix build .#nixosConfigurations.tower...toplevel: pass; pi and leod toplevels evaluate.service.d/10-ntfy-failure.conf, the mask atntfy-failure@.service.d/10-ntfy-failure.conf, and thentfy-failure@.servicetemplate withntfy-wrapped(topic URL baked in) + coreutils + systemd on PATH,TimeoutStartSec=15s, and the rendered script callingntfy publish --quiet --title ….ntfy publishmechanics checked against a mock HTTP server: stdin becomes the message body,--titlesets theX-Titleheader,NTFY_TOPICsupplies the topic, and the CLI exits 0 on a 200 / non-zero on a 429 (so a failed delivery lands insystemctl --failed). One live publish to tower's ntfy returned exit 0.OnFailure=ntfy-failure@dummy-ntfy-test.service.serviceshows on the dummy unit (%n expanded);OnFailure=empty andDropInPathslists only the mask;Result=success).🤖 Generated with Claude Code