{ config, lib, pkgs, homeyConfig, ... }: # Ntfy — self-hosted push notification server. # # Mobile app (Android/iOS) connects to https://ntfy.zakobar.com with a token # and subscribes to the "alerts" topic. Uptime Kuma and Grafana send alerts # to that topic when services go down. # # Auth model: # - Web UI: public-facing but ntfy enforces its own auth (deny-all by default) # - Caddy does NOT put forward_auth here; ntfy has native token/password auth # so the mobile app can connect without Authelia SSO complications. # # Web Push (PWA via Safari "Add to Home Screen"): # Generate VAPID keys on the Pi: # sudo ntfy webpush keys # Set homey.ntfy.webPushPublicKey and homey.ntfy.webPushEmail in default.nix. # Add the private key to sops: ntfy/web_push_private_key # # Setup after first deploy: # 1. Visit https://ntfy.zakobar.com — log in with the admin password from sops. # 2. Create an access token for your phone (Admin → Users & Tokens). # 3. PWA: open https://ntfy.zakobar.com in Safari → Share → Add to Home Screen, # then open from Home Screen and subscribe to "alerts". # # Volume layout: # /ntfy/auth.db ← user/token database # /ntfy/cache.db ← message cache (for missed messages) # /ntfy/attachments/ ← file attachments # # Secrets consumed from sops: # ntfy/admin_password # ntfy/web_push_private_key let cfg = config.homey.ntfy; dataDir = config.homey.storage.mountPoint; domain = homeyConfig.domain; # All ntfy settings in one place. The private key is NOT here — it is # injected at runtime via ExecStartPre so it never lands in the nix store. ntfySettings = { listen-http = "127.0.0.1:${toString cfg.port}"; base-url = "https://ntfy.${domain}"; auth-default-access = "deny-all"; auth-file = "${dataDir}/ntfy/auth.db"; cache-file = "${dataDir}/ntfy/cache.db"; attachment-root = "${dataDir}/ntfy/attachments"; upstream-base-url = "https://ntfy.sh"; cache-duration = "12h"; attachment-total-size-limit = "5G"; attachment-file-size-limit = "15M"; attachment-expiry-duration = "3h"; web-push-public-key = cfg.webPushPublicKey; web-push-email-address = cfg.webPushEmail; web-push-file = "${dataDir}/ntfy/webpush.db"; }; # Build-time base config (no private key). ExecStartPre copies this to # /run/ntfy-sh/server.yml and appends web-push-private-key from the credential. baseConfigFile = (pkgs.formats.yaml {}).generate "ntfy-server-base.yml" ntfySettings; in { options.homey.ntfy = { enable = lib.mkEnableOption "Ntfy push notification server"; port = lib.mkOption { type = lib.types.port; default = 2586; description = "Host port ntfy listens on (bound to 127.0.0.1)."; }; webPushPublicKey = lib.mkOption { type = lib.types.str; description = "VAPID public key for Web Push (generate with: sudo ntfy webpush keys)."; }; webPushEmail = lib.mkOption { type = lib.types.str; description = "Contact e-mail sent in VAPID headers (e.g. mailto:you@example.com)."; }; }; config = lib.mkIf cfg.enable { # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- sops.secrets."ntfy/admin_password" = { owner = "root"; }; sops.secrets."ntfy/web_push_private_key" = { owner = "root"; }; # ----------------------------------------------------------------------- # ntfy-sh native NixOS service # ----------------------------------------------------------------------- services.ntfy-sh = { enable = true; settings = ntfySettings; }; # Minimal config for the `ntfy user` CLI — the NixOS module puts its # generated config in the nix store under an unpredictable path, so we # write a separate file just containing the auth-file path. The server # ignores this file (it uses the module-generated one via -c flag). environment.etc."ntfy-sh/user-cli.yml" = { text = "auth-file: ${dataDir}/ntfy/auth.db\n"; mode = "0444"; }; # Create ntfy data directories on the external HD before ntfy starts. # Runs as a separate root service (outside ntfy-sh's restricted namespace) # so it can access /mnt/data without hitting ReadWritePaths restrictions. systemd.services.ntfy-sh-mkdir = { description = "Create Ntfy data directories on external HD"; wantedBy = [ "ntfy-sh.service" ]; before = [ "ntfy-sh.service" ]; after = [ "mnt-data.mount" ]; requires = [ "mnt-data.mount" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = pkgs.writeShellScript "ntfy-mkdir" '' set -euo pipefail mkdir -p ${dataDir}/ntfy/attachments chown -R ntfy-sh:ntfy-sh ${dataDir}/ntfy chmod 0750 ${dataDir}/ntfy ${dataDir}/ntfy/attachments ''; }; }; # Ensure ntfy-sh starts after the HD is mounted and dirs are ready. # Widen ReadWritePaths so ntfy-sh can write to the external HD. # Inject the VAPID private key at runtime: ExecStartPre copies the # build-time base config to /run/ntfy-sh/server.yml and appends the key, # then we override ExecStart to use that runtime config file. systemd.services.ntfy-sh = { after = lib.mkAfter [ "mnt-data.mount" "ntfy-sh-mkdir.service" ]; requires = lib.mkAfter [ "mnt-data.mount" "ntfy-sh-mkdir.service" ]; serviceConfig = { ReadWritePaths = lib.mkAfter [ "${dataDir}/ntfy" ]; RuntimeDirectory = "ntfy-sh"; # creates /run/ntfy-sh, owned by ntfy-sh user # Run as root (+) so the module's sandbox hardening can't block the write. # Read the sops secret directly — no LoadCredential needed. ExecStartPre = "+" + toString (pkgs.writeShellScript "ntfy-write-config" '' set -euo pipefail mkdir -p /run/ntfy-sh cp ${baseConfigFile} /run/ntfy-sh/server.yml printf 'web-push-private-key: %s\n' \ "$(cat ${config.sops.secrets."ntfy/web_push_private_key".path})" \ >> /run/ntfy-sh/server.yml chown ntfy-sh:ntfy-sh /run/ntfy-sh/server.yml chmod 600 /run/ntfy-sh/server.yml ''); ExecStart = lib.mkForce "${pkgs.ntfy-sh}/bin/ntfy serve -c /run/ntfy-sh/server.yml"; }; }; # ----------------------------------------------------------------------- # Create the admin user on first start (idempotent) # ----------------------------------------------------------------------- systemd.services.ntfy-sh-setup = { description = "Create Ntfy admin user"; wantedBy = [ "multi-user.target" ]; after = [ "ntfy-sh.service" "mnt-data.mount" ]; requires = [ "ntfy-sh.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; LoadCredential = "ntfy_admin_password:${config.sops.secrets."ntfy/admin_password".path}"; ExecStart = pkgs.writeShellScript "ntfy-create-admin" '' set -euo pipefail # Wait until ntfy HTTP endpoint is ready (max 60 s) for i in $(seq 1 30); do if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/v1/health > /dev/null 2>&1; then break fi sleep 2 done PASS=$(cat "$CREDENTIALS_DIRECTORY/ntfy_admin_password") # Use the minimal CLI config (just has auth-file path). NTFY="${pkgs.ntfy-sh}/bin/ntfy user --config /etc/ntfy-sh/user-cli.yml" # ntfy user add reads password + confirmation from stdin (two lines). # If the user already exists ntfy exits 1 with "already exists" — treat that as success. if out=$(printf '%s\n%s\n' "$PASS" "$PASS" | $NTFY add --role=admin admin 2>&1); then echo "ntfy-sh-setup: admin user created" elif echo "$out" | grep -q "already exists"; then echo "ntfy-sh-setup: admin user already exists (ok)" else echo "$out" >&2 exit 1 fi ''; }; }; # ----------------------------------------------------------------------- # Uptime Kuma monitor for this service # ----------------------------------------------------------------------- homey.monitoring.monitors = [{ name = "Ntfy"; url = "https://ntfy.${domain}/v1/health"; interval = 60; }]; }; }