diff --git a/docs/superpowers/specs/2026-07-05-ntfy-module-design.md b/docs/superpowers/specs/2026-07-05-ntfy-module-design.md new file mode 100644 index 0000000..cc945f7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-ntfy-module-design.md @@ -0,0 +1,176 @@ +# ntfy self-hosted notification module — design + +Date: 2026-07-05 +Target machine: jupiter + +## Purpose + +Add a NixOS profile module that runs and configures a self-hosted +[ntfy](https://ntfy.sh) notification server on jupiter, with authentication +required, a homepage dashboard tile, and out-of-the-box plumbing so Home +Assistant automations can publish push notifications. + +## Scope decisions (from brainstorming) + +- **Service:** ntfy (self-hosted server), not Apprise/Gotify/HA-only. +- **Access control:** authentication required (`auth-default-access = deny-all`). +- **Home Assistant:** the module wires an HA `rest_command` so automations can + send notifications out of the box. +- **Credentials:** fully manual. The module runs the server and wires the HA + plumbing, but does **not** seed users or store any secret. The operator + provisions ntfy users/passwords by hand, and the only secret lives in Home + Assistant's `secrets.yaml` — never in the world-readable Nix store. + +## Module structure + +New module at `modules/environments/ntfy/default.nix` following the repo's +standard profile pattern (`my.profiles.` with `options` + +`config = lib.mkIf cfg.enable { ... }`). + +Registration: +1. Add `./ntfy` to the `imports` list in `modules/environments/default.nix`. +2. Enable via `my.profiles.ntfy.enable = true;` in + `machines/jupiter/environments.nix`. + +### Options + +```nix +options.my.profiles.ntfy = with lib; { + enable = mkEnableOption "ntfy notification server"; + + port = mkOption { + type = types.port; + default = 2586; + description = "HTTP port ntfy listens on."; + }; + + topic = mkOption { + type = types.str; + default = "ha"; + description = "Topic Home Assistant publishes notifications to."; + }; + + haIntegration.enable = mkOption { + type = types.bool; + default = true; + description = "Wire a Home Assistant rest_command that publishes to ntfy."; + }; +}; +``` + +## Server configuration + +```nix +config = lib.mkIf cfg.enable { + services.ntfy-sh = { + enable = true; + settings = { + base-url = "http://${hostName}:${toString cfg.port}"; + listen-http = ":${toString cfg.port}"; + auth-file = "/var/lib/ntfy-sh/user.db"; + auth-default-access = "deny-all"; + }; + }; + + networking.firewall.allowedTCPPorts = [ cfg.port ]; + + my.homepage.services = [ + { + group = "Services"; + name = "ntfy"; + description = "Push notifications"; + href = "http://${hostName}:${toString cfg.port}"; + icon = "ntfy.svg"; + } + ]; +}; +``` + +`hostName` is bound from `config.networking.hostName`, matching the pattern in +the home-assistant and paperless modules. + +## Home Assistant wiring + +Applied only when ntfy, the HA integration flag, and the home-assistant profile +are all enabled: + +```nix +lib.mkIf (cfg.enable && cfg.haIntegration.enable + && config.my.profiles.home-assistant.enable) { + services.home-assistant.config.rest_command.ntfy_send = { + url = "http://${hostName}:${toString cfg.port}/${cfg.topic}"; + method = "POST"; + payload = "{{ message }}"; + content_type = "text/plain"; + username = "homeassistant"; + password = "!secret ntfy_password"; + headers = { + Title = "{{ title | default('Home Assistant') }}"; + Priority = "{{ priority | default('default') }}"; + }; + }; +}; +``` + +Notes: +- `services.home-assistant.config` is an attrset that NixOS merges, so adding + `rest_command.ntfy_send` from this module composes with the config the + home-assistant module already defines. +- `password = "!secret ntfy_password"` is a whole-value `!secret` reference. The + upstream home-assistant module unquotes such values when rendering the config + (the same mechanism the repo already relies on for `!include`), so the secret + resolves from `/var/lib/hass/secrets.yaml` at runtime and never enters the + Nix store. See `reference_nixos_ha_yaml_includes`. +- `rest_command` is chosen over the `notify` REST platform because ntfy's + per-topic URL path plus header-based metadata map cleanly onto rest_command, + whereas the notify platform's fixed JSON payload fights ntfy's format. + +## Manual provisioning (operator runbook) + +Because credentials are fully manual, after the first `nixos-rebuild switch` +run these once on jupiter: + +```bash +# Dedicated publisher for Home Assistant, scoped to the ha topic +ntfy user add homeassistant # prompts for a password +ntfy access homeassistant ha write-only + +# Admin account for the app / web UI +ntfy user add --role=admin admin +``` + +Then add the homeassistant password to Home Assistant's secrets: + +```yaml +# /var/lib/hass/secrets.yaml +ntfy_password: +``` + +Restart Home Assistant. Automations can then publish with: + +```yaml +service: rest_command.ntfy_send +data: + message: "Garage door left open" + title: "Alert" + priority: high +``` + +Subscribers (phone/desktop ntfy app) log in as `admin` (or another user granted +read access) to receive messages. + +## Testing / verification + +- Build check: `nix build '.#nixosConfigurations.jupiter.config.system.build.toplevel'` + must succeed with the module enabled. +- `nixfmt-rfc-style` clean on the new file. +- Post-deploy manual verification (documented, not automated): create the users + above, publish a test message from HA, confirm it reaches a subscribed client. + +## Out of scope + +- No automated user/token seeding (explicitly deferred to manual provisioning). +- No sops-nix setup (repo has dangling `config.sops.secrets` references, but + wiring up sops is a separate change and not required here). +- No reverse-proxy / TLS termination (`behind-proxy` left default; LAN-only via + firewall).