diff --git a/README.org b/README.org index dcb72dd..d425c23 100644 --- a/README.org +++ b/README.org @@ -134,17 +134,45 @@ Some services require manual one-time configuration after the first deploy. ** Ntfy — push notifications -Ntfy's admin user is created automatically from sops on first start. You -still need to create a phone token and subscribe to the alerts topic. +Ntfy's admin user is created automatically from sops on first start. -1. Visit =https://ntfy.zakobar.com= and log in with the admin password - (=ntfy/admin_password= in =secrets/secrets.yaml=). -2. Go to *Account → Access Tokens → Create token* — give it a name (e.g. - "phone") and copy the token value. -3. In the [[https://ntfy.sh][Ntfy mobile app]]: - - *Server*: =https://ntfy.zakobar.com= - - *Access token*: the token you just created -4. Subscribe to the =alerts= topic in the app. +*** Step 1 — Generate VAPID keys (Web Push) + +Run on the Pi *before* the first full deploy: + +#+begin_src bash +ssh admin@192.168.1.100 'sudo ntfy webpush keys' +#+end_src + +This prints a public key and a private key. + +- Copy the *public key* into =hosts/pi-main/default.nix=: + #+begin_src nix + homey.ntfy.webPushPublicKey = ""; + homey.ntfy.webPushEmail = "mailto:you@zakobar.com"; + #+end_src +- Add the *private key* to sops: + #+begin_src bash + sops secrets/secrets.yaml + # add: ntfy/web_push_private_key: + #+end_src + +The private key is injected at boot and never lands in the nix store. + +*** Step 2 — Subscribe via Safari PWA (recommended for iOS) + +1. Visit =https://ntfy.zakobar.com= in Safari and log in with the admin + password (=ntfy/admin_password= in =secrets/secrets.yaml=). +2. Go to *Account → Access Tokens → Create token* — give it a name and + copy the value. +3. Log in with the token, then tap *Share → Add to Home Screen*. +4. Open the app from the Home Screen (must be launched from there, not + Safari, to get push permission). +5. Subscribe to the =alerts= topic and grant notification permission when + prompted. + +Web Push via the PWA uses Apple's APNs directly and is more reliable on +iOS than the native ntfy app's upstream relay. ** Uptime Kuma — notifications (two-deploy process) diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 3f22980..03aca29 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -98,6 +98,10 @@ # Monitoring stack homey.uptimeKuma.enable = true; homey.ntfy.enable = true; + # Generate with: ssh admin@192.168.1.100 'sudo ntfy webpush keys' + # Add private key to sops: ntfy/web_push_private_key + homey.ntfy.webPushPublicKey = "BE2qZVa3JEF741WTPtLevyhfP0I8bV0sD2a9-_y9NoyC40sgLpQi7bcoZesBwZEpRz8oiTVuoUFnHbckAsBQI5U"; + homey.ntfy.webPushEmail = "aner@zakobar.com"; homey.monitoring.enable = true; # Backups diff --git a/modules/services/ntfy.nix b/modules/services/ntfy.nix index 9d59066..e2c3c35 100644 --- a/modules/services/ntfy.nix +++ b/modules/services/ntfy.nix @@ -11,11 +11,17 @@ # - 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. In the Ntfy app: server = https://ntfy.zakobar.com, token = . -# 4. Subscribe to the "alerts" topic. +# 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 @@ -24,11 +30,35 @@ # # 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 = { @@ -39,40 +69,31 @@ in 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/admin_password" = { owner = "root"; }; + sops.secrets."ntfy/web_push_private_key" = { owner = "root"; }; # ----------------------------------------------------------------------- # ntfy-sh native NixOS service # ----------------------------------------------------------------------- services.ntfy-sh = { - enable = true; - settings = { - # Bind to localhost; Caddy reverse-proxies it - listen-http = "127.0.0.1:${toString cfg.port}"; - base-url = "https://ntfy.${domain}"; - - # Require auth on all topics — deny unauthenticated access entirely - auth-default-access = "deny-all"; - - # Persistent state on external HD - auth-file = "${dataDir}/ntfy/auth.db"; - cache-file = "${dataDir}/ntfy/cache.db"; - attachment-root = "${dataDir}/ntfy/attachments"; - - # Keep messages for 12 hours so the app catches up if offline - cache-duration = "12h"; - - # Attachment limits - attachment-total-size-limit = "5G"; - attachment-file-size-limit = "15M"; - attachment-expiry-duration = "3h"; - }; + enable = true; + settings = ntfySettings; }; # Minimal config for the `ntfy user` CLI — the NixOS module puts its @@ -107,12 +128,30 @@ in }; # Ensure ntfy-sh starts after the HD is mounted and dirs are ready. - # Also widen ReadWritePaths so ntfy-sh can write to the external HD path - # (the NixOS module restricts writes to /var/lib/ntfy-sh by default). + # 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" ]; + 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"; + }; }; # ----------------------------------------------------------------------- @@ -145,13 +184,15 @@ in # 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 list outputs a Unicode table; grep for admin in it. # ntfy user add reads password + confirmation from stdin (two lines). - if $NTFY list 2>/dev/null | grep -qE "admin"; then - echo "ntfy-sh-setup: admin user already exists" - else - printf '%s\n%s\n' "$PASS" "$PASS" | $NTFY add --role=admin admin + # 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 ''; }; diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index 81ecf05..bc8c132 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -2,6 +2,7 @@ uptime-kuma: admin_password: ENC[AES256_GCM,data:tPKWxWmxRcVJeywY3J4eXAWWnAinLwMn3X68TrV/4emonvRiuyPmiwhn2fjDxwB/kT78y/iDDmpdQY229yJrkQ==,iv:YSL40PDbRTgtSYZCwqHzfJTcEAiILIDbGRA2kfamiw8=,tag:pMM0AWkjkcS9XOaSHG1oUQ==,type:str] ntfy: admin_password: ENC[AES256_GCM,data:P5pjnt00lyeGVlrBvUlJWWeTi3evFZPJIxjcsndbo4LZOLk6hbbrh8RwCAGzr1ump0A5fRXqynByRFdaS6++wA==,iv:Uxeh0/mygR++4S//O/RO2bouH2J0qcSCYtjjyZNooNk=,tag:LGIDaq4RzBuzrWFqVDr8ow==,type:str] + web_push_private_key: ENC[AES256_GCM,data:BggPo7uYjda48iV3G8TaPk7mPZXHv+H6MW3BeMYFaxYCVAok0zT7Tzko7A==,iv:qPX8N4mzD4DWX2tWlsQCK09PD0R4ntrJMqYOqwwzGXg=,tag:pXIp3pAkYQpdbXG/PtsFag==,type:str] grafana: secret_key: ENC[AES256_GCM,data:/KNDMZZN5thoqsgJZS7fuNQULI1PAKVuihRu9WzO00Qw8js/V4KKJT0JOVOcqdHAnf44+szYZaCWt0xe02chGw==,iv:Y0FQ7h4SqZVtz0wLjPnVGGYyXmBIDi8nzaK2GFzDxqQ=,tag:w0z5/vI3Hfd8ry9DCHAvJw==,type:str] openldap: @@ -41,8 +42,8 @@ sops: QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-05-10T08:30:12Z" - mac: ENC[AES256_GCM,data:4nZyQLRlIWmgawj7YgL9DZAbbA/bQrOpfmFPlwaoPJR16eOQOHzxLLYrM6iyJxhYyt9d6iaFpFO9g9KOvOCDCG9s8/EImrXg0WDYIJn7D+ftGc0Qj5augBqeJEh9DgfDoWiXAYrCR3lUfDAswaSAPAjf1NzuStlM9X0SNyW+1Ug=,iv:mBq1xiTbjMX834yTnBR6+/IP8vZTYS8UB3v1z0wEc8s=,tag:BQWlnIj636Lv6a2CZjcV0w==,type:str] + lastmodified: "2026-05-10T20:36:03Z" + mac: ENC[AES256_GCM,data:aEC9pHnupssTzcw9HdtqkzzhsNkkJMYT3qiwKPLCcIfDMN1Lv3Msi0TJyFjqjR/vzOfAyHFgsPjSWFladL7fOZHpqq2VeNYHPF9/GKEuoEMqsISN2FczqrTHNC8aI/vhZxe3BxgkX9neiHR9v31MRpX9lq6AbCrEJ42hCh6rCxs=,iv:41Dx92loo4zgKt+7iqjgaOZZUe58VAGEGgOEHEuztzQ=,tag:Oagh8hxWfXWuNI70WHULYA==,type:str] pgp: - created_at: "2026-04-21T06:39:49Z" enc: |-