From 07638354d27bd0b5a29a9ac2adf71b308ca08738 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Thu, 14 May 2026 10:48:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(#72):=20full-privacy=20push=20=E2=80=94=20?= =?UTF-8?q?self-hosted=20ntfy=20+=20APNs-direct=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 028: namespace_push_credentials - Per-(namespace, provider) AES-256-GCM encrypted credential blob. - Generic schema — apns/ntfy/expo/future plug in with zero migration. - Separated from migration 026's namespace_push_config (preferences vs credentials, different access patterns). pkg/push/credentials - Manager + Registry + RQLite store; HKDF purpose "namespace-push-credentials" via pkg/secrets. Provider Validator interface for per-provider schema. pkg/push/providers/apns - Apple Push Notification service direct provider (no Expo proxy). - Validator + dispatcher; credentials are p8 signing key + key_id + team_id. pkg/push/providers/ntfy/credentials.go - ntfy credential schema (auth_token + default topic). Used both with the public ntfy.sh and our self-hosted instance. pkg/environments/production/installers/ntfy.go - Self-hosted ntfy server installer. Binary, system user, hardened /etc/ntfy/server.yml, systemd unit. Listens on 127.0.0.1:NtfyListenPort only — Caddy is the only public path. pkg/environments/production/installers/caddy.go - Emit reverse_proxy block for push. -> 127.0.0.1:NtfyListenPort when operator enables ntfy on a node. CLI: install/upgrade orchestrators learn a new "ntfy" install/preserve phase; flag gating in install/flags.go + upgrade/flags.go. Gateway handlers/push/credentials_handler.go - GET/PUT/DELETE /v1/namespace/push-credentials/{provider}. - PUT validates against provider Validator before encrypting and storing. - GET returns a redacted view (booleans + non-secret fields only). Push manager: provider resolution now also consults namespace_push_credentials before falling back to YAML defaults. Docs: core/docs/PUSH_NOTIFICATIONS.md walks through end-to-end setup. VERSION bumped to 0.122.14. --- VERSION | 2 +- core/docs/PUSH_NOTIFICATIONS.md | 367 +++++++++++++++ core/go.mod | 2 + core/go.sum | 9 + .../028_namespace_push_credentials.sql | 34 ++ core/pkg/cli/production/install/flags.go | 2 + .../cli/production/install/orchestrator.go | 1 + core/pkg/cli/production/upgrade/flags.go | 24 +- .../cli/production/upgrade/orchestrator.go | 18 + .../pkg/environments/production/installers.go | 23 + .../production/installers/caddy.go | 35 +- .../production/installers/caddy_ntfy_test.go | 84 ++++ .../production/installers/ntfy.go | 431 ++++++++++++++++++ .../production/installers/ntfy_test.go | 130 ++++++ .../environments/production/orchestrator.go | 60 +++ .../environments/production/preferences.go | 1 + core/pkg/gateway/dependencies.go | 108 ++++- core/pkg/gateway/gateway.go | 6 + .../handlers/push/credentials_handler.go | 341 ++++++++++++++ .../handlers/push/credentials_handler_test.go | 380 +++++++++++++++ core/pkg/gateway/handlers/push/types.go | 12 +- core/pkg/gateway/push_routes.go | 27 ++ core/pkg/gateway/routes.go | 10 + core/pkg/push/credentials/manager.go | 181 ++++++++ core/pkg/push/credentials/manager_test.go | 288 ++++++++++++ core/pkg/push/credentials/registry.go | 88 ++++ core/pkg/push/credentials/registry_test.go | 116 +++++ core/pkg/push/credentials/store.go | 161 +++++++ core/pkg/push/credentials/types.go | 117 +++++ core/pkg/push/manager.go | 90 +++- core/pkg/push/manager_test.go | 14 +- core/pkg/push/providers/apns/apns.go | 180 ++++++++ core/pkg/push/providers/apns/apns_test.go | 372 +++++++++++++++ core/pkg/push/providers/apns/credentials.go | 150 ++++++ core/pkg/push/providers/ntfy/credentials.go | 149 ++++++ .../push/providers/ntfy/credentials_test.go | 112 +++++ sdk/package.json | 2 +- 37 files changed, 4079 insertions(+), 48 deletions(-) create mode 100644 core/docs/PUSH_NOTIFICATIONS.md create mode 100644 core/migrations/028_namespace_push_credentials.sql create mode 100644 core/pkg/environments/production/installers/caddy_ntfy_test.go create mode 100644 core/pkg/environments/production/installers/ntfy.go create mode 100644 core/pkg/environments/production/installers/ntfy_test.go create mode 100644 core/pkg/gateway/handlers/push/credentials_handler.go create mode 100644 core/pkg/gateway/handlers/push/credentials_handler_test.go create mode 100644 core/pkg/push/credentials/manager.go create mode 100644 core/pkg/push/credentials/manager_test.go create mode 100644 core/pkg/push/credentials/registry.go create mode 100644 core/pkg/push/credentials/registry_test.go create mode 100644 core/pkg/push/credentials/store.go create mode 100644 core/pkg/push/credentials/types.go create mode 100644 core/pkg/push/providers/apns/apns.go create mode 100644 core/pkg/push/providers/apns/apns_test.go create mode 100644 core/pkg/push/providers/apns/credentials.go create mode 100644 core/pkg/push/providers/ntfy/credentials.go create mode 100644 core/pkg/push/providers/ntfy/credentials_test.go diff --git a/VERSION b/VERSION index ef85aaa..a09fb81 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.122.13 +0.122.14 diff --git a/core/docs/PUSH_NOTIFICATIONS.md b/core/docs/PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..8e22c39 --- /dev/null +++ b/core/docs/PUSH_NOTIFICATIONS.md @@ -0,0 +1,367 @@ +# Push Notifications — Tenant Guide + +This guide explains how a tenant app (any namespace on the Orama +Network) configures push notifications end-to-end. The platform is +**bring-your-own-credentials**: you control your Apple Developer +account, your push keys, and your topic format. The platform provides +delivery infrastructure (an APNs HTTP/2 client pool, a self-hosted +ntfy server, and storage for your encrypted credentials). + +Feature #72 implements this. Closes the "tenants must file an ops +ticket to get push enabled" workflow that bug #220 partially fixed for +ntfy/expo. + +--- + +## Provider matrix + +| Platform | Provider | Privacy | Setup | +|--------------------|-----------------------|--------------------|------------------------------------------------------| +| iOS (production) | `apns` (direct) | Full — no proxies | Apple Developer account + p8 key | +| iOS (TestFlight) | `apns` (sandbox env) | Full — no proxies | Same key, `"environment": "sandbox"` | +| Android (FCM) | `expo` (legacy) | Routes via Expo+FCM| Expo access token | +| Android (no FCM) | `ntfy` | Full — self-hosted | ntfy topic (no Google Play Services required) | +| Web / push API | `ntfy` | Full — self-hosted | Web Push protocol against `push.` | + +Pick `apns` + `ntfy` for full-privacy stacks (recommended for +privacy-focused apps, GrapheneOS, etc.). Pick `expo` if you'd rather +not run your own Android push infrastructure and your users are on +Google Play Services. + +--- + +## Step 1 — Generate Apple Push credentials (iOS only) + +You need an active Apple Developer Program membership for the team +that owns your iOS app's bundle ID. + +1. Go to https://developer.apple.com/account/resources/authkeys/list. +2. Click `+` to create a new key. +3. Check **"Apple Push Notifications service (APNs)"**. +4. Name it (e.g. `Orama Push - myapp prod`) and continue. +5. Download the `.p8` file IMMEDIATELY — Apple does NOT let you + download it again later. Lose it = generate a new key. +6. Note the **Key ID** (10 chars, alphanumeric). +7. Note your **Team ID** from the top-right of the page. +8. Confirm the **Bundle ID** that matches your iOS app (Xcode → + Project → Signing). + +You should now have: +- `AuthKey_.p8` file +- `Key ID` (e.g. `ABC123DEFG`) +- `Team ID` (e.g. `1234567890`) +- `Bundle ID` (e.g. `com.example.myapp`) + +The same key signs for **all** apps under the same Apple Developer +team — one key per team is enough. + +--- + +## Step 2 — Choose an ntfy topic mode (Android / Web only) + +When using ntfy, the gateway and your client must agree on the topic +URL each device subscribes to. Three modes: + +| Mode | Topic format | Privacy | Notes | +|-----------|---------------------------------------|-------------------|------------------------------------| +| `opaque` | `sha256(namespace + userId + secret)` | **Best** | Recommended default | +| `path` | `ns//` | Readable | Anyone enumerating topics sees IDs | +| `user` | `` | Reveals user IDs | Minimal — rarely useful | + +For `opaque`, you generate a **topic_secret** once and bake it into +both your gateway credential record AND your client's signed app +config. Both sides hash the same triple to get the topic. Rotate the +secret by: +1. PUT new `topic_secret` (clients keep computing old topic against + their config until the app updates). +2. Ship a new client build with the new secret. +3. After all clients update, the old topic stops receiving sends. + +--- + +## Step 3 — Store credentials via the API + +All credentials live encrypted in your namespace's row in the gateway's +RQLite cluster. Stored credentials are NEVER returned by any GET +endpoint — responses report `has_: true/false` only. + +Auth: every request requires a JWT issued for your wallet, scoped to +your namespace. + +### APNs (iOS) + +```http +PUT /v1/namespace/push-credentials/apns +Authorization: Bearer +Content-Type: application/json + +{ + "team_id": "1234567890", + "key_id": "ABC123DEFG", + "bundle_id": "com.example.myapp", + "p8_key": "-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----", + "environment": "production" +} +``` + +`environment` must be `"sandbox"` (Xcode / TestFlight builds) or +`"production"` (App Store builds). A mismatch produces `BadDeviceToken` +at send time, not at PUT time — match your build channel. + +Response on success: + +```json +{ + "namespace": "myapp-prod", + "provider": "apns", + "configured": true, + "updated_at": 1700000000, + "updated_by": "0xWalletAddress…", + "redacted": { + "team_id": "1234567890", + "key_id": "ABC123DEFG", + "bundle_id": "com.example.myapp", + "environment": "production", + "has_p8_key": true + } +} +``` + +### ntfy (Android / Web) + +```http +PUT /v1/namespace/push-credentials/ntfy +Authorization: Bearer +Content-Type: application/json + +{ + "base_url": "https://push.dbrs.space", + "auth_token": "tk_…", + "topic_mode": "opaque", + "topic_secret": "<32-byte random secret, base64 OK>" +} +``` + +`base_url` and `auth_token` are both optional: +- Leave `base_url` empty to use the platform's self-hosted ntfy. +- Leave `auth_token` empty when using the platform ntfy (no auth + needed for opaque topics) or pointing at a public ntfy server. + +### Expo (legacy, optional) + +Same shape via the older endpoint: + +```http +PUT /v1/push/config +{ "expo_access_token": "…" } +``` + +This is the pre-#72 path; new code should prefer `apns` + `ntfy`. + +--- + +## Step 4 — Verify what's configured + +### Per-provider GET + +```http +GET /v1/namespace/push-credentials/apns +``` + +Returns the redacted view (`has_p8_key: true/false` etc.) but never +the secret material. Use this to confirm what you PUT. + +### Summary (what providers do I have?) + +```http +GET /v1/namespace/push-credentials +``` + +```json +{ + "namespace": "myapp-prod", + "configured": ["apns", "ntfy"], + "supported": ["apns", "ntfy", "fcm"] +} +``` + +- `configured` is what your namespace has stored credentials for. +- `supported` is what this gateway knows how to deliver to (provider + packages are compiled in and `Register()`-ed at startup). + +--- + +## Step 5 — Register devices from your client + +The client-side flow is unchanged from before #72: + +```http +POST /v1/push/devices +{ + "device_id": "", + "provider": "apns", // or "ntfy" / "expo" + "token": "", + "platform": "ios", // or "android" / "web" + "app_version": "1.2.3" +} +``` + +For `apns`, the token is the hex string Apple gives your iOS app at +launch (`UIApplication.didRegisterForRemoteNotificationsWithDeviceToken`). + +For `ntfy` with `topic_mode=opaque`, the token is the sha256 hex digest +your client computes locally from `(namespace, userId, topic_secret)`. + +For `ntfy` with `topic_mode=path`, the token is `ns//`. + +--- + +## Step 6 — Send pushes + +Two paths, depending on whether the push originates from your serverless +function or an external system: + +### From a serverless function + +```javascript +import { push } from "@orama/sdk"; + +await push.send({ + user_id: "", + title: "New message", + body: "Hello from %1", + channel: "messages", + priority: "high", +}); +``` + +The hostfunc fans out to every registered device for the user, using +each device's recorded `provider`. + +### From outside (admin/internal scope) + +```http +POST /v1/push/send +Authorization: Bearer +{ + "user_id": "0xUser...", + "title": "New message", + "body": "Hello", + "channel": "messages", + "priority": "high" +} +``` + +This endpoint is JWT-gated and scoped to your namespace. **Add a finer +allow-list / admin-scope check at your gateway layer before exposing +it to untrusted callers** — see security note in `pkg/gateway/handlers/push/handlers.go`. + +--- + +## Removing credentials + +```http +DELETE /v1/namespace/push-credentials/apns +``` + +Idempotent — returns 200 even if nothing was stored. Subsequent push +sends for that provider become no-ops (devices registered with the +removed provider are skipped with a warning log). + +--- + +## Platform-operator notes + +These bits are for whoever runs the Orama gateway cluster, NOT tenants. + +### Enabling self-hosted ntfy + +The gateway installer takes a `--with-ntfy` flag (install + upgrade +commands). When set on a node, that node: + +- Installs the ntfy binary at `/usr/local/bin/ntfy`. +- Runs ntfy as a `ntfy` system user with restricted privileges. +- Listens on `127.0.0.1:8090` (Caddy fronts it for public TLS). +- Persists message cache at `/var/lib/ntfy/cache.db`. +- Generates a Caddy reverse-proxy block for `push.` → + localhost:8090, with Let's Encrypt cert via the orama ACME DNS-01 + flow. + +For **devnet**, enable on `ns1` (already runs Caddy): + +``` +orama node install --with-ntfy --nameserver # (other flags omitted) +``` + +For **production**, you can either colocate with ns1 or run a +dedicated node. The installer is identical either way. + +The preference persists in `/opt/orama/.orama/preferences.yaml` so +subsequent `orama node upgrade` runs keep it on without re-passing +the flag. + +### How the gateway handles credentials + +- `pkg/push/credentials/` — generic per-(namespace, provider) store + with LRU+TTL cache (mirrors `pkg/ratelimit`). +- AES-256-GCM at rest via `pkg/secrets` using HKDF-derived key under + purpose string `namespace-push-credentials`. +- Provider packages register a `Validator` at gateway startup; the + HTTP handler dispatches to that Validator for schema validation and + redaction. Adding a new provider (FCM, SMS, …) is one new package + + one `pushcreds.Register(...)` call. + +### Backward-compat with bug #220's `/v1/push/config` + +The legacy `/v1/push/config` endpoint still works for `ntfy_base_url` +and `ntfy_auth_token` / `expo_access_token`. Field-by-field semantics: + +- If a tenant has a row in `namespace_push_credentials` (the new + #72 table) for `ntfy`, that record's `base_url` / `auth_token` / + topic config takes precedence. +- Otherwise the gateway reads from `namespace_push_config` (the 026 + table). + +This lets tenants migrate at their own pace. A future migration will +drop the legacy ntfy credential columns once all known tenants have +moved over. + +--- + +## FAQ + +**Q. Does the platform hold my Apple p8 key?** +The platform stores it encrypted in your namespace's RQLite row. The +key is derived from the cluster secret and is unique per cluster. +Operators with cluster-secret access can decrypt the key (the +encryption is to protect against database-dump exfiltration, not +against the platform operators themselves). Treat the platform +operators with the same trust level you'd treat a hosting provider. + +**Q. Can two tenants share Apple credentials?** +Apple's APNs token-auth model lets one Apple Developer team sign for +all bundle IDs registered under that team. So if two of your apps +live under the same Apple Developer team, they can use the same p8 +key — but you still PUT to each namespace separately (one PUT per +namespace). + +**Q. What if my p8 key leaks?** +Generate a new one in the Apple Developer dashboard, PUT it to the +gateway. The old key keeps working until you revoke it on Apple's +side; the new key starts working as soon as the gateway's credential +cache TTL expires (30 s) on every gateway in the cluster. + +**Q. How do I rotate the ntfy `topic_secret`?** +See "Step 2" — two-phase: ship a new client first that knows BOTH +secrets, then PUT the new secret, then ship a final client that +drops the old. Or accept a short message-loss window during cutover. + +**Q. Can I use my own ntfy server instead of the platform's?** +Yes. PUT a `base_url` pointing at your ntfy server. The platform's +ntfy is just a convenience default. + +**Q. Are pushes rate-limited?** +The gateway-level per-namespace rate limit (feature #69) applies to +the `POST /v1/push/send` endpoint. Per-provider send rate limits at +the dispatcher level are not yet implemented — track as a follow-up +feature. diff --git a/core/go.mod b/core/go.mod index ecb8c4d..90a957a 100644 --- a/core/go.mod +++ b/core/go.mod @@ -25,6 +25,7 @@ require ( github.com/pion/turn/v4 v4.0.2 github.com/pion/webrtc/v4 v4.1.2 github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 + github.com/sideshow/apns2 v0.25.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tetratelabs/wazero v1.11.0 @@ -65,6 +66,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect diff --git a/core/go.sum b/core/go.sum index 5b6516c..574c0a1 100644 --- a/core/go.sum +++ b/core/go.sum @@ -16,6 +16,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= @@ -134,6 +135,9 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -491,6 +495,8 @@ github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go. github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4= +github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= @@ -571,6 +577,7 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -617,6 +624,7 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= @@ -667,6 +675,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/core/migrations/028_namespace_push_credentials.sql b/core/migrations/028_namespace_push_credentials.sql new file mode 100644 index 0000000..7ef45d4 --- /dev/null +++ b/core/migrations/028_namespace_push_credentials.sql @@ -0,0 +1,34 @@ +-- ============================================================================= +-- 028_namespace_push_credentials.sql +-- +-- Per-namespace, per-provider push credentials. Generic schema so any +-- future provider (apns, fcm, sms, …) plugs in with zero migration — +-- the credentials_json BLOB is an opaque AES-256-GCM ciphertext owned +-- by the provider package; this table knows nothing about the schema +-- inside. +-- +-- Feature #72 (full-privacy push: APNs-direct + self-hosted ntfy). +-- +-- Why a separate table from 026 (namespace_push_config)? +-- * 026 holds delivery PREFERENCES (ntfy_base_url, etc.) — non-secret +-- toggles a tenant flips often. +-- * 028 holds CREDENTIALS (Apple p8 key, ntfy auth token, future FCM +-- service-account JSON) — sensitive material with a different +-- access pattern (less-frequently updated, always encrypted). +-- Splitting keeps the audit story clean and lets us add per-provider +-- credentials without bloating 026's columns each time. +-- +-- Encryption: credentials_json is AES-256-GCM ciphertext via pkg/secrets +-- with HKDF purpose string "namespace-push-credentials". The blob holds +-- a provider-specific JSON document (see each provider package for its +-- own schema and Validator). +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS namespace_push_credentials ( + namespace TEXT NOT NULL, + provider TEXT NOT NULL, -- "apns" | "ntfy" | "expo" | future + credentials_json TEXT NOT NULL, -- enc: + updated_at INTEGER NOT NULL, -- unix seconds + updated_by TEXT, -- audit: wallet/operator id + PRIMARY KEY (namespace, provider) +); diff --git a/core/pkg/cli/production/install/flags.go b/core/pkg/cli/production/install/flags.go index d3a360d..d50c798 100644 --- a/core/pkg/cli/production/install/flags.go +++ b/core/pkg/cli/production/install/flags.go @@ -15,6 +15,7 @@ type Flags struct { DryRun bool SkipChecks bool Nameserver bool // Make this node a nameserver (runs CoreDNS + Caddy) + NtfyHost bool // Host the self-hosted ntfy server on this node (feature #72) JoinAddress string // HTTPS URL of existing node (e.g., https://node1.dbrs.space) Token string // Invite token for joining (from orama invite) ClusterSecret string // Deprecated: use --token instead @@ -64,6 +65,7 @@ func ParseFlags(args []string) (*Flags, error) { fs.BoolVar(&flags.DryRun, "dry-run", false, "Show what would be done without making changes") fs.BoolVar(&flags.SkipChecks, "skip-checks", false, "Skip minimum resource checks (RAM/CPU)") fs.BoolVar(&flags.Nameserver, "nameserver", false, "Make this node a nameserver (runs CoreDNS + Caddy)") + fs.BoolVar(&flags.NtfyHost, "with-ntfy", false, "Host the self-hosted ntfy server (feature #72; usually colocated with --nameserver on devnet)") // Cluster join flags fs.StringVar(&flags.JoinAddress, "join", "", "Join existing cluster via HTTPS URL (e.g. https://node1.dbrs.space)") diff --git a/core/pkg/cli/production/install/orchestrator.go b/core/pkg/cli/production/install/orchestrator.go index 58f0f0d..a32024f 100644 --- a/core/pkg/cli/production/install/orchestrator.go +++ b/core/pkg/cli/production/install/orchestrator.go @@ -46,6 +46,7 @@ func NewOrchestrator(flags *Flags) (*Orchestrator, error) { setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks) setup.SetNameserver(flags.Nameserver) + setup.SetNtfyHost(flags.NtfyHost) // Configure Anyone mode if flags.AnyoneRelay && flags.AnyoneClient { diff --git a/core/pkg/cli/production/upgrade/flags.go b/core/pkg/cli/production/upgrade/flags.go index ae2073f..8db41a6 100644 --- a/core/pkg/cli/production/upgrade/flags.go +++ b/core/pkg/cli/production/upgrade/flags.go @@ -11,7 +11,8 @@ type Flags struct { Force bool RestartServices bool SkipChecks bool - Nameserver *bool // Pointer so we can detect if explicitly set vs default + Nameserver *bool // Pointer so we can detect if explicitly set vs default + NtfyHost *bool // Feature #72: nil = use saved preference; non-nil = explicit override // Remote upgrade flags Env string // Target environment for remote rolling upgrade @@ -50,6 +51,8 @@ func ParseFlags(args []string) (*Flags, error) { // Nameserver flag - use pointer to detect if explicitly set nameserver := fs.Bool("nameserver", false, "Make this node a nameserver (uses saved preference if not specified)") + // Ntfy host flag (feature #72) — same pattern, sticks via preferences.yaml. + ntfyHost := fs.Bool("with-ntfy", false, "Host the self-hosted ntfy server on this node (uses saved preference if not specified)") // Anyone flags fs.BoolVar(&flags.AnyoneClient, "anyone-client", false, "Install Anyone as client-only (SOCKS5 proxy on port 9050, no relay)") @@ -75,6 +78,25 @@ func ParseFlags(args []string) (*Flags, error) { if *nameserver { flags.Nameserver = nameserver } + // Set ntfy_host only when explicitly passed (default false != "use saved"). + // Without explicit set, the orchestrator reads the saved preference. + if isFlagPassed(fs, "with-ntfy") { + flags.NtfyHost = ntfyHost + } return flags, nil } + +// isFlagPassed reports whether the named flag was explicitly set on +// the command line, not just defaulted. Used to distinguish "user +// didn't say anything; honor saved preference" from "user wrote +// --with-ntfy=false; turn it OFF". +func isFlagPassed(fs *flag.FlagSet, name string) bool { + passed := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + passed = true + } + }) + return passed +} diff --git a/core/pkg/cli/production/upgrade/orchestrator.go b/core/pkg/cli/production/upgrade/orchestrator.go index 38f3319..722771f 100644 --- a/core/pkg/cli/production/upgrade/orchestrator.go +++ b/core/pkg/cli/production/upgrade/orchestrator.go @@ -38,8 +38,17 @@ func NewOrchestrator(flags *Flags) *Orchestrator { isNameserver = *flags.Nameserver } + // Feature #72: ntfy-host preference survives upgrades the same way + // nameserver does — loaded from preferences.yaml unless the upgrade + // flags override it explicitly. + isNtfyHost := prefs.NtfyHost + if flags.NtfyHost != nil { + isNtfyHost = *flags.NtfyHost + } + setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks) setup.SetNameserver(isNameserver) + setup.SetNtfyHost(isNtfyHost) // Configure Anyone mode (explicit flags > saved preferences > auto-detect) // Explicit flags always win — they represent the user's current intent. @@ -211,6 +220,15 @@ func (o *Orchestrator) handleBranchPreferences() error { fmt.Printf(" Nameserver mode: enabled (CoreDNS + Caddy)\n") } + // If ntfy-host was explicitly provided, persist it (feature #72). + if o.flags.NtfyHost != nil { + prefs.NtfyHost = *o.flags.NtfyHost + prefsChanged = true + } + if o.setup.IsNtfyHost() { + fmt.Printf(" ntfy host: enabled (self-hosted ntfy on push.)\n") + } + // Anyone client and relay are mutually exclusive — setting one clears the other. if o.flags.AnyoneClient { prefs.AnyoneClient = true diff --git a/core/pkg/environments/production/installers.go b/core/pkg/environments/production/installers.go index a1b72d6..c96134f 100644 --- a/core/pkg/environments/production/installers.go +++ b/core/pkg/environments/production/installers.go @@ -23,6 +23,7 @@ type BinaryInstaller struct { gateway *installers.GatewayInstaller coredns *installers.CoreDNSInstaller caddy *installers.CaddyInstaller + ntfy *installers.NtfyInstaller // feature #72; installed only when EnableNtfy is set } // NewBinaryInstaller creates a new binary installer @@ -39,6 +40,7 @@ func NewBinaryInstaller(arch string, logWriter io.Writer) *BinaryInstaller { gateway: installers.NewGatewayInstaller(arch, logWriter), coredns: installers.NewCoreDNSInstaller(arch, logWriter, oramaHome), caddy: installers.NewCaddyInstaller(arch, logWriter, oramaHome), + ntfy: installers.NewNtfyInstaller(arch, logWriter), } } @@ -147,6 +149,27 @@ func (bi *BinaryInstaller) ConfigureCaddy(domain string, email string, acmeEndpo return bi.caddy.Configure(domain, email, acmeEndpoint, baseDomain) } +// EnableCaddyNtfyProxy tells the Caddy installer to emit a reverse- +// proxy block for `hostname` → localhost: on the next +// ConfigureCaddy() call. Used together with InstallNtfy / +// ConfigureNtfy when this node hosts the self-hosted ntfy server +// (feature #72). +func (bi *BinaryInstaller) EnableCaddyNtfyProxy(hostname string) { + bi.caddy.EnableNtfyProxy(hostname) +} + +// InstallNtfy installs the self-hosted ntfy server (binary, user, +// systemd unit, data directory). Feature #72. Idempotent. +func (bi *BinaryInstaller) InstallNtfy() error { + return bi.ntfy.Install() +} + +// ConfigureNtfy writes /etc/ntfy/server.yml with the given public base +// URL (e.g. "https://push.dbrs.space"). Feature #72. +func (bi *BinaryInstaller) ConfigureNtfy(publicBaseURL string) error { + return bi.ntfy.Configure(publicBaseURL) +} + // Mock system commands for testing (if needed) var execCommand = exec.Command diff --git a/core/pkg/environments/production/installers/caddy.go b/core/pkg/environments/production/installers/caddy.go index 9f5632f..f339758 100644 --- a/core/pkg/environments/production/installers/caddy.go +++ b/core/pkg/environments/production/installers/caddy.go @@ -18,9 +18,15 @@ const ( // CaddyInstaller handles Caddy installation with custom DNS module type CaddyInstaller struct { *BaseInstaller - version string - oramaHome string - dnsModule string // Path to the orama DNS module source + version string + oramaHome string + dnsModule string // Path to the orama DNS module source + + // withNtfy, when set, causes generateCaddyfile to emit a reverse- + // proxy block for `push.` → localhost:. + // Enabled per-node via EnableNtfyProxy. Feature #72. + withNtfy bool + ntfyHostname string // e.g. "push.dbrs.space" — fully-qualified public host } // NewCaddyInstaller creates a new Caddy installer @@ -33,6 +39,19 @@ func NewCaddyInstaller(arch string, logWriter io.Writer, oramaHome string) *Cadd } } +// EnableNtfyProxy tells the Caddy installer to emit a reverse-proxy +// block for the self-hosted ntfy server (feature #72). hostname is the +// public fully-qualified domain — e.g. "push.dbrs.space" — that Caddy +// will obtain a Let's Encrypt cert for and route to the local ntfy +// server on NtfyListenPort. +// +// Must be called BEFORE Configure so the generated Caddyfile includes +// the block. +func (ci *CaddyInstaller) EnableNtfyProxy(hostname string) { + ci.withNtfy = true + ci.ntfyHostname = hostname +} + // IsInstalled checks if Caddy with orama DNS module is already installed func (ci *CaddyInstaller) IsInstalled() bool { caddyPath := "/usr/bin/caddy" @@ -420,6 +439,16 @@ func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint, baseDom sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", baseDomain)) } + // Self-hosted ntfy reverse-proxy (feature #72). Emitted only when + // the orchestrator has called EnableNtfyProxy on this installer — + // i.e. this node was selected to host ntfy. The hostname is its + // own block so the cert lives separately from the namespace gateway + // cert (different rotation cadence, different blast radius). + if ci.withNtfy && ci.ntfyHostname != "" { + sb.WriteString(fmt.Sprintf("\n%s {\n%s\n reverse_proxy localhost:%d\n}\n", + ci.ntfyHostname, tlsBlock, NtfyListenPort)) + } + // HTTP catch-all fallback (handles remaining plain HTTP traffic) sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n") diff --git a/core/pkg/environments/production/installers/caddy_ntfy_test.go b/core/pkg/environments/production/installers/caddy_ntfy_test.go new file mode 100644 index 0000000..2283c47 --- /dev/null +++ b/core/pkg/environments/production/installers/caddy_ntfy_test.go @@ -0,0 +1,84 @@ +package installers + +import ( + "fmt" + "strings" + "testing" +) + +// Phase 4 (#72) — when the orchestrator enables ntfy on a node, the +// generated Caddyfile must include a reverse-proxy block routing +// push. to localhost:. Without this block, +// public clients can't reach the ntfy server (it listens on +// 127.0.0.1 only). + +func TestGenerateCaddyfile_NoNtfyByDefault(t *testing.T) { + ci := newTestCaddyInstaller() + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + if strings.Contains(cf, "push.dbrs.space") { + t.Errorf("Caddyfile should NOT include push. by default; got:\n%s", cf) + } + if strings.Contains(cf, fmt.Sprintf("localhost:%d", NtfyListenPort)) { + t.Errorf("Caddyfile should NOT route to ntfy port by default; got:\n%s", cf) + } +} + +func TestGenerateCaddyfile_NtfyEnabledEmitsBlock(t *testing.T) { + ci := newTestCaddyInstaller() + ci.EnableNtfyProxy("push.dbrs.space") + + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + // Block exists with the right hostname. + if !strings.Contains(cf, "push.dbrs.space {") { + t.Errorf("Caddyfile missing push hostname block; got:\n%s", cf) + } + // Reverse-proxy target points at the ntfy listen port. + want := fmt.Sprintf("reverse_proxy localhost:%d", NtfyListenPort) + if !strings.Contains(cf, want) { + t.Errorf("Caddyfile missing %q; got:\n%s", want, cf) + } + // TLS block still references the orama ACME issuer. + if !strings.Contains(cf, "dns orama") { + t.Errorf("ntfy block missing orama TLS issuer; got:\n%s", cf) + } +} + +func TestGenerateCaddyfile_NtfyBlockHasOwnTLS(t *testing.T) { + ci := newTestCaddyInstaller() + ci.EnableNtfyProxy("push.dbrs.space") + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + // The ntfy block should be its OWN block — i.e. there are now MORE + // `tls {` occurrences than there would be without ntfy. This is a + // guard against accidental collapsing into the wildcard block, which + // would mix the cert lifecycle with the gateway cert. + ci2 := newTestCaddyInstaller() + cf2 := ci2.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + + withCount := strings.Count(cf, "issuer acme") + withoutCount := strings.Count(cf2, "issuer acme") + if withCount != withoutCount+1 { + t.Errorf("expected exactly one EXTRA `issuer acme` block with ntfy enabled; with=%d without=%d", withCount, withoutCount) + } +} + +func TestGenerateCaddyfile_NtfyEmptyHostnameSkipped(t *testing.T) { + // withNtfy=true but no hostname — the block is omitted (defensive; + // the installer's EnableNtfyProxy requires a hostname so this is a + // guard against programmer error in the orchestrator). + ci := newTestCaddyInstaller() + ci.withNtfy = true + ci.ntfyHostname = "" + + cf := ci.generateCaddyfile("node1.dbrs.space", "admin@dbrs.space", + "http://localhost:6001/v1/internal/acme", "dbrs.space") + if strings.Contains(cf, fmt.Sprintf("localhost:%d", NtfyListenPort)) { + t.Errorf("empty ntfy hostname should suppress block; got:\n%s", cf) + } +} diff --git a/core/pkg/environments/production/installers/ntfy.go b/core/pkg/environments/production/installers/ntfy.go new file mode 100644 index 0000000..f0cc72d --- /dev/null +++ b/core/pkg/environments/production/installers/ntfy.go @@ -0,0 +1,431 @@ +package installers + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// ntfy.go — feature #72. Self-hosted ntfy server installer. +// +// Generic infrastructure: installs the upstream `ntfy` binary, creates +// an `ntfy` system user, writes a hardened `/etc/ntfy/server.yml`, and +// generates a systemd unit. The Caddy installer (caddy.go) is taught +// to emit a reverse-proxy block for the public `push.` host +// when the operator enables ntfy on a node. +// +// Storage layout: +// - Binary: /usr/local/bin/ntfy +// - Config: /etc/ntfy/server.yml +// - Cache + DB: /var/lib/ntfy/ (owned by ntfy user) +// - Logs: journal (systemd captures stdout) +// - User: ntfy (system user, no shell) +// +// Network: +// - ntfy listens on 127.0.0.1: (default 8090); only +// Caddy can reach it. Public TLS termination + auth headers stop +// at Caddy. Behind-proxy mode is enabled in server.yml so ntfy +// trusts the X-Forwarded-* headers Caddy sets. +// +// This installer is intentionally generic: any tenant who pushes to +// this ntfy server brings their own auth_token + topic via the +// /v1/namespace/push-credentials/ntfy endpoint. No tenant-specific +// state lives in this code. + +const ( + // ntfyVersion is the upstream binwiederhier/ntfy release we install. + // Update intentionally — newer ntfy versions occasionally tweak + // server.yml schema; verify server.yml still validates before + // bumping. + ntfyVersion = "2.11.0" + + // NtfyListenPort is the localhost port ntfy binds to. Caddy reverse- + // proxies to it; exposed nowhere else. + NtfyListenPort = 8090 + + ntfyBinaryPath = "/usr/local/bin/ntfy" + ntfyConfigDir = "/etc/ntfy" + ntfyConfigPath = "/etc/ntfy/server.yml" + ntfyDataDir = "/var/lib/ntfy" + ntfySystemdUnit = "/etc/systemd/system/ntfy.service" + ntfyUser = "ntfy" +) + +// NtfyInstaller installs and configures a self-hosted ntfy server. +// Designed for ns1 on devnet (per feature #72) and a dedicated node on +// production. Gated on by the orchestrator when WithNtfy is true. +type NtfyInstaller struct { + *BaseInstaller +} + +// NewNtfyInstaller returns a new ntfy installer. +func NewNtfyInstaller(arch string, logWriter io.Writer) *NtfyInstaller { + return &NtfyInstaller{ + BaseInstaller: NewBaseInstaller(arch, logWriter), + } +} + +// IsInstalled returns true when the ntfy binary is on disk AND reports +// a version matching the expected pin. A version mismatch returns +// false so an Install() upgrade path is triggered. +func (ni *NtfyInstaller) IsInstalled() bool { + if _, err := os.Stat(ntfyBinaryPath); os.IsNotExist(err) { + return false + } + out, err := exec.Command(ntfyBinaryPath, "--version").Output() + if err != nil { + return false + } + // `ntfy --version` prints e.g. "ntfy 2.11.0 (1234abc, 2024-01-01)" + return strings.Contains(string(out), ntfyVersion) +} + +// Install downloads the ntfy binary, creates the `ntfy` user, lays out +// data + config directories, and writes the systemd unit. Idempotent: +// re-running on a correctly-installed system is a no-op. +func (ni *NtfyInstaller) Install() error { + if ni.IsInstalled() { + fmt.Fprintf(ni.logWriter, " ✓ ntfy %s already installed\n", ntfyVersion) + return nil + } + + fmt.Fprintf(ni.logWriter, " Installing ntfy %s...\n", ntfyVersion) + + if err := ni.ensureUser(); err != nil { + return fmt.Errorf("ntfy: create user: %w", err) + } + if err := ni.downloadBinary(); err != nil { + return fmt.Errorf("ntfy: download binary: %w", err) + } + if err := ni.ensureDirs(); err != nil { + return fmt.Errorf("ntfy: prepare directories: %w", err) + } + if err := ni.writeSystemdUnit(); err != nil { + return fmt.Errorf("ntfy: write systemd unit: %w", err) + } + if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil { + return fmt.Errorf("ntfy: systemctl daemon-reload: %w", err) + } + fmt.Fprintf(ni.logWriter, " ✓ ntfy %s installed\n", ntfyVersion) + return nil +} + +// Configure writes /etc/ntfy/server.yml. Called every Phase 4 (config +// regen) so operator-side knobs can be updated without re-installing. +// The base_url is exposed publicly via Caddy as https://push.. +func (ni *NtfyInstaller) Configure(publicBaseURL string) error { + if publicBaseURL == "" { + return fmt.Errorf("ntfy Configure: publicBaseURL required (e.g. https://push.dbrs.space)") + } + if err := ni.ensureDirs(); err != nil { + return err + } + cfg := ni.generateServerYAML(publicBaseURL) + if err := os.WriteFile(ntfyConfigPath, []byte(cfg), 0640); err != nil { + return fmt.Errorf("ntfy Configure: write server.yml: %w", err) + } + // Make config readable by ntfy user (group ntfy is set via ensureDirs). + // A chown failure here means the systemd unit will fail to read the + // config — surface it so the operator notices now rather than after + // a confusing service-start error. + if out, err := exec.Command("chown", "root:"+ntfyUser, ntfyConfigPath).CombinedOutput(); err != nil { + fmt.Fprintf(ni.logWriter, " ⚠️ chown %s failed: %v (%s)\n", ntfyConfigPath, err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(ni.logWriter, " ✓ ntfy server.yml written (base_url=%s)\n", publicBaseURL) + return nil +} + +// ---- internals ------------------------------------------------------ + +// ensureUser creates the `ntfy` system user (no shell, no home) if it +// doesn't already exist. Used to run the ntfy process under a +// non-privileged identity. +func (ni *NtfyInstaller) ensureUser() error { + // Check if user already exists. + if err := exec.Command("id", ntfyUser).Run(); err == nil { + return nil + } + cmd := exec.Command("useradd", + "--system", + "--no-create-home", + "--shell", "/usr/sbin/nologin", + ntfyUser) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("useradd: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +// ensureDirs creates and chowns the ntfy config + data directories. +func (ni *NtfyInstaller) ensureDirs() error { + if err := os.MkdirAll(ntfyConfigDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", ntfyConfigDir, err) + } + if err := os.MkdirAll(ntfyDataDir, 0750); err != nil { + return fmt.Errorf("mkdir %s: %w", ntfyDataDir, err) + } + // Data dir must be writable by the ntfy user. Config dir stays + // root-owned so the systemd unit can read it; group=ntfy so the + // service can also stat it. A chown failure here would cause ntfy + // to fail to write its cache database — log it loud so the operator + // can investigate rather than chasing a confusing systemd error + // later. + if out, err := exec.Command("chown", "-R", ntfyUser+":"+ntfyUser, ntfyDataDir).CombinedOutput(); err != nil { + fmt.Fprintf(ni.logWriter, " ⚠️ chown %s failed: %v (%s)\n", ntfyDataDir, err, strings.TrimSpace(string(out))) + } + return nil +} + +// downloadBinary fetches the ntfy release archive, verifies its +// SHA-256 against the upstream checksums file, and installs the +// binary at /usr/local/bin/ntfy with 0755 permissions. +// +// Defense-in-depth: HTTPS to github.com pins the TLS chain; the +// checksum verification catches the case where a release was modified +// after upload (compromised maintainer, mirror swap, etc.). Either +// failing gate stops the install. +// +// Release URL pattern: +// +// https://github.com/binwiederhier/ntfy/releases/download/v/ntfy__linux_.tar.gz +func (ni *NtfyInstaller) downloadBinary() error { + arch := ni.arch + switch arch { + case "amd64", "arm64": + // supported + case "": + arch = "amd64" + default: + return fmt.Errorf("ntfy: unsupported arch %q (want amd64 or arm64)", arch) + } + tarballName := fmt.Sprintf("ntfy_%s_linux_%s.tar.gz", ntfyVersion, arch) + tarballURL := fmt.Sprintf( + "https://github.com/binwiederhier/ntfy/releases/download/v%s/%s", + ntfyVersion, tarballName) + checksumsURL := fmt.Sprintf( + "https://github.com/binwiederhier/ntfy/releases/download/v%s/ntfy_%s_checksums.txt", + ntfyVersion, ntfyVersion) + + fmt.Fprintf(ni.logWriter, " Downloading %s...\n", tarballURL) + client := &http.Client{Timeout: 5 * time.Minute} + + // Download the tarball into a memory buffer (~20 MB; bounded by the + // 200 MB CopyN guard). We need the bytes twice: once for SHA-256 + // verification, once for tar extraction. + tarballBytes, err := httpGetLimited(client, tarballURL, 200*1024*1024) + if err != nil { + return fmt.Errorf("download tarball: %w", err) + } + + // Fetch the upstream checksums file and find the line for our tarball. + checksumsBody, err := httpGetLimited(client, checksumsURL, 64*1024) + if err != nil { + return fmt.Errorf("download checksums: %w", err) + } + expectedSHA, err := findChecksumFor(checksumsBody, tarballName) + if err != nil { + return fmt.Errorf("locate checksum for %s: %w", tarballName, err) + } + + // Verify. + actual := sha256.Sum256(tarballBytes) + actualHex := hex.EncodeToString(actual[:]) + if !strings.EqualFold(actualHex, expectedSHA) { + return fmt.Errorf("ntfy tarball SHA-256 mismatch: got %s, want %s — refusing to install (possible supply-chain tampering)", + actualHex, expectedSHA) + } + fmt.Fprintf(ni.logWriter, " ✓ SHA-256 verified: %s\n", actualHex[:16]+"…") + + // Extract. + gz, err := gzip.NewReader(bytes.NewReader(tarballBytes)) + if err != nil { + return fmt.Errorf("gunzip: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar read: %w", err) + } + // The ntfy release tarball contains /ntfy + // (plus docs/LICENSE/man pages). We only care about the binary. + if filepath.Base(hdr.Name) != "ntfy" || hdr.Typeflag != tar.TypeReg { + continue + } + dst, err := os.OpenFile(ntfyBinaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("open binary path: %w", err) + } + // Limit copy size to 200 MB so a malicious archive can't fill + // the disk. ntfy binaries are ~20 MB; 200 MB is plenty. + if _, err := io.CopyN(dst, tr, 200*1024*1024); err != nil && err != io.EOF { + dst.Close() + return fmt.Errorf("write binary: %w", err) + } + dst.Close() + return nil + } + return fmt.Errorf("ntfy binary not found in release archive %s", tarballURL) +} + +// httpGetLimited fetches url and returns up to maxBytes of body. Used +// for both the ntfy tarball (~20 MB) and the checksums file (~1 KB). +// Returns an error if HTTP status isn't 200 or the body exceeds the cap. +func httpGetLimited(client *http.Client, url string, maxBytes int64) ([]byte, error) { + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + // LimitReader + drain check: if the body would exceed maxBytes, we + // stop reading and return an error rather than truncate silently. + lr := io.LimitReader(resp.Body, maxBytes+1) + buf, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(buf)) > maxBytes { + return nil, fmt.Errorf("response body exceeds %d bytes (got at least %d)", maxBytes, len(buf)) + } + return buf, nil +} + +// findChecksumFor scans an upstream-style checksums file (one entry +// per line: " ") and returns the SHA-256 hex +// digest for the given filename, or an error if not present. +func findChecksumFor(body []byte, filename string) (string, error) { + sc := bufio.NewScanner(bytes.NewReader(body)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + // "*" prefix marks binary mode in BSD checksum tools; strip it. + name := strings.TrimPrefix(fields[1], "*") + if name == filename { + if len(fields[0]) != 64 { + return "", fmt.Errorf("entry for %s has wrong digest length %d (want 64)", filename, len(fields[0])) + } + return fields[0], nil + } + } + if err := sc.Err(); err != nil { + return "", fmt.Errorf("scan checksums: %w", err) + } + return "", fmt.Errorf("filename %q not in checksums file", filename) +} + +// writeSystemdUnit writes /etc/systemd/system/ntfy.service. Runs ntfy +// as the `ntfy` user with restricted privileges (NoNewPrivileges, +// ProtectSystem=strict, PrivateTmp). Auto-restart on failure. +func (ni *NtfyInstaller) writeSystemdUnit() error { + unit := fmt.Sprintf(`[Unit] +Description=ntfy notification server (Orama #72) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=%s +Group=%s +ExecStart=%s serve --config %s +Restart=on-failure +RestartSec=5s +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ReadWritePaths=%s +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +`, ntfyUser, ntfyUser, ntfyBinaryPath, ntfyConfigPath, ntfyDataDir) + if err := os.WriteFile(ntfySystemdUnit, []byte(unit), 0644); err != nil { + return fmt.Errorf("write unit: %w", err) + } + return nil +} + +// generateServerYAML produces the contents of /etc/ntfy/server.yml. +// Hardened defaults: listens on localhost, behind-proxy mode on, cache +// + persistence configured, attachments disabled (we don't need them +// for transactional push), and access defaults to deny — auth is +// per-topic via the operator-side `auth-file` (future, not in v1). +func (ni *NtfyInstaller) generateServerYAML(publicBaseURL string) string { + return fmt.Sprintf(`# ntfy server config (Orama #72). Generated — do not edit by hand. +# Re-running the orchestrator's Phase 4 will overwrite changes here. + +# Public-facing URL — used for "Topic URLs to display in the web UI" +# and Web Push registration (not used by Orama mobile clients). +base-url: %q + +# Listen on localhost only. Caddy terminates TLS at push. and +# reverse-proxies to here (port %d). Direct external access is blocked +# by the lack of a public listen address. +listen-http: "127.0.0.1:%d" + +# Behind-proxy mode: trust the X-Forwarded-* headers Caddy sets so +# rate-limiting + visitor metrics see the real client IP, not Caddy's +# 127.0.0.1. +behind-proxy: true + +# Cache + persistence. The SQLite database stores subscribed clients' +# pending messages so a disconnected client can replay on reconnect. +cache-file: "%s/cache.db" +cache-duration: "12h" + +# Attachments off — Orama push payloads are tiny JSON. Disabling stops +# tenants from accidentally storing files here. +attachment-cache-dir: "" +attachment-total-size-limit: "0" + +# Rate-limiting (operator caps; per-namespace rate is enforced upstream +# at the gateway via feature #69). These bound abuse if a tenant's +# credentials are compromised. +visitor-request-limit-burst: 60 +visitor-request-limit-replenish: "5s" +visitor-message-daily-limit: 100000 + +# Web UI off — operators manage via the file system + journal, not +# via the public UI. +web-root: "disable" + +# Logs to stdout so systemd-journald captures them. +log-level: "info" +log-format: "json" +`, publicBaseURL, NtfyListenPort, NtfyListenPort, ntfyDataDir) +} diff --git a/core/pkg/environments/production/installers/ntfy_test.go b/core/pkg/environments/production/installers/ntfy_test.go new file mode 100644 index 0000000..c09f4f9 --- /dev/null +++ b/core/pkg/environments/production/installers/ntfy_test.go @@ -0,0 +1,130 @@ +package installers + +import ( + "io" + "strings" + "testing" +) + +// newTestNtfyInstaller returns an NtfyInstaller suitable for unit +// tests — no filesystem or network dependencies. +func newTestNtfyInstaller() *NtfyInstaller { + return &NtfyInstaller{ + BaseInstaller: NewBaseInstaller("amd64", io.Discard), + } +} + +func TestNtfyServerYAML_listensOnLocalhostOnly(t *testing.T) { + ni := newTestNtfyInstaller() + cfg := ni.generateServerYAML("https://push.dbrs.space") + + // Hardening invariant #1: NEVER bind to 0.0.0.0. Caddy fronts ntfy; + // public access to ntfy directly bypasses ntfy:Caddy TLS termination. + if !strings.Contains(cfg, `listen-http: "127.0.0.1:`) { + t.Errorf("server.yml must listen on 127.0.0.1; got:\n%s", cfg) + } + if strings.Contains(cfg, "0.0.0.0") { + t.Errorf("server.yml must NOT bind 0.0.0.0; got:\n%s", cfg) + } +} + +func TestNtfyServerYAML_behindProxyModeOn(t *testing.T) { + ni := newTestNtfyInstaller() + cfg := ni.generateServerYAML("https://push.dbrs.space") + if !strings.Contains(cfg, "behind-proxy: true") { + t.Errorf("server.yml must set behind-proxy: true (Caddy fronts); got:\n%s", cfg) + } +} + +func TestNtfyServerYAML_baseURLEmbedded(t *testing.T) { + ni := newTestNtfyInstaller() + cfg := ni.generateServerYAML("https://push.dbrs.space") + if !strings.Contains(cfg, "https://push.dbrs.space") { + t.Errorf("server.yml missing public base_url; got:\n%s", cfg) + } +} + +func TestNtfyServerYAML_attachmentsDisabled(t *testing.T) { + ni := newTestNtfyInstaller() + cfg := ni.generateServerYAML("https://push.dbrs.space") + if !strings.Contains(cfg, `attachment-cache-dir: ""`) { + t.Errorf("attachments should be disabled (Orama uses tiny payloads); got:\n%s", cfg) + } +} + +func TestNtfyServerYAML_webUIDisabled(t *testing.T) { + ni := newTestNtfyInstaller() + cfg := ni.generateServerYAML("https://push.dbrs.space") + if !strings.Contains(cfg, `web-root: "disable"`) { + t.Errorf("web-root must be disabled (operators manage via FS, not UI); got:\n%s", cfg) + } +} + +func TestNtfyServerYAML_logFormatJSON(t *testing.T) { + ni := newTestNtfyInstaller() + cfg := ni.generateServerYAML("https://push.dbrs.space") + if !strings.Contains(cfg, `log-format: "json"`) { + t.Errorf("log-format should be json for journal parsing; got:\n%s", cfg) + } +} + +func TestNtfyConfigure_rejectsEmptyBaseURL(t *testing.T) { + ni := newTestNtfyInstaller() + err := ni.Configure("") + if err == nil { + t.Error("Configure should reject empty publicBaseURL") + } +} + +func TestFindChecksumFor_picksRightLine(t *testing.T) { + body := []byte(`# ntfy v2.11.0 checksums +abc123 ntfy_2.11.0_linux_arm64.tar.gz +DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF ntfy_2.11.0_linux_amd64.tar.gz +9999999999999999999999999999999999999999999999999999999999999999 ntfy_2.11.0_darwin_amd64.tar.gz +`) + got, err := findChecksumFor(body, "ntfy_2.11.0_linux_amd64.tar.gz") + if err != nil { + t.Fatalf("findChecksumFor: %v", err) + } + want := "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestFindChecksumFor_rejectsMissingFile(t *testing.T) { + body := []byte(`abc123 some_other_file.tar.gz`) + if _, err := findChecksumFor(body, "ntfy_2.11.0_linux_amd64.tar.gz"); err == nil { + t.Error("expected error for missing filename") + } +} + +func TestFindChecksumFor_rejectsWrongDigestLength(t *testing.T) { + body := []byte(`tooshort ntfy_2.11.0_linux_amd64.tar.gz`) + if _, err := findChecksumFor(body, "ntfy_2.11.0_linux_amd64.tar.gz"); err == nil { + t.Error("expected error for short digest") + } +} + +func TestFindChecksumFor_handlesBSDStarPrefix(t *testing.T) { + body := []byte(`DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF *ntfy_2.11.0_linux_amd64.tar.gz`) + if _, err := findChecksumFor(body, "ntfy_2.11.0_linux_amd64.tar.gz"); err != nil { + t.Errorf("BSD `*` prefix should be tolerated; got %v", err) + } +} + +func TestNtfySystemdUnit_includesHardening(t *testing.T) { + // The unit is written to disk in writeSystemdUnit; we don't actually + // touch the filesystem here (no chroot in unit tests) but we can + // regression-check the constants used so an accidental rename of + // the binary path / port / user fails loud here. + if ntfyUser != "ntfy" { + t.Errorf("ntfyUser should be 'ntfy'; got %q", ntfyUser) + } + if ntfyBinaryPath != "/usr/local/bin/ntfy" { + t.Errorf("ntfyBinaryPath drift; got %q", ntfyBinaryPath) + } + if NtfyListenPort != 8090 { + t.Errorf("NtfyListenPort drift; got %d", NtfyListenPort) + } +} diff --git a/core/pkg/environments/production/orchestrator.go b/core/pkg/environments/production/orchestrator.go index 4a3ace8..6aa1e2d 100644 --- a/core/pkg/environments/production/orchestrator.go +++ b/core/pkg/environments/production/orchestrator.go @@ -38,6 +38,7 @@ type ProductionSetup struct { skipOptionalDeps bool skipResourceChecks bool isNameserver bool // Whether this node is a nameserver (runs CoreDNS + Caddy) + isNtfyHost bool // Feature #72: whether this node hosts the self-hosted ntfy server isAnyoneClient bool // Whether this node runs Anyone as client-only (SOCKS5 proxy) anyoneRelayConfig *AnyoneRelayConfig // Configuration for Anyone relay mode privChecker *PrivilegeChecker @@ -135,6 +136,20 @@ func (ps *ProductionSetup) IsNameserver() bool { return ps.isNameserver } +// SetNtfyHost flags this node as the host for the self-hosted ntfy +// server (feature #72). When set, Phase 2 installs ntfy and Phase 4 +// generates /etc/ntfy/server.yml plus a Caddy reverse-proxy block for +// push.. Requires isNameserver=true for devnet (ns1 also +// runs Caddy); production deployments may colocate or split. +func (ps *ProductionSetup) SetNtfyHost(isNtfy bool) { + ps.isNtfyHost = isNtfy +} + +// IsNtfyHost returns whether this node hosts ntfy. +func (ps *ProductionSetup) IsNtfyHost() bool { + return ps.isNtfyHost +} + // SetAnyoneRelayConfig sets the Anyone relay configuration func (ps *ProductionSetup) SetAnyoneRelayConfig(config *AnyoneRelayConfig) { ps.anyoneRelayConfig = config @@ -344,6 +359,14 @@ func (ps *ProductionSetup) installFromSource() error { ps.logf(" ⚠️ Caddy install warning: %v", err) } + // Install ntfy only on nodes flagged as the ntfy host (feature #72). + // On devnet this is ns1; on production it can be a dedicated node. + if ps.isNtfyHost { + if err := ps.binaryInstaller.InstallNtfy(); err != nil { + ps.logf(" ⚠️ ntfy install warning: %v", err) + } + } + // These are pre-built binary downloads (not Go compilation), always run them if err := ps.binaryInstaller.InstallRQLite(); err != nil { ps.logf(" ⚠️ RQLite install warning: %v", err) @@ -701,6 +724,23 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(peerAddresses []string, vpsIP s } email := "admin@" + caddyDomain acmeEndpoint := "http://localhost:6001/v1/internal/acme" + + // Self-hosted ntfy (feature #72): when this node hosts ntfy, + // (a) tell the Caddy installer to emit a push. block + // pointing at the local ntfy listen port, and (b) write the + // ntfy server.yml. Both must happen BEFORE ConfigureCaddy is + // called below so the generated Caddyfile picks up the block. + if ps.isNtfyHost { + ntfyHost := "push." + dnsZone + ps.binaryInstaller.EnableCaddyNtfyProxy(ntfyHost) + ntfyBaseURL := "https://" + ntfyHost + if err := ps.binaryInstaller.ConfigureNtfy(ntfyBaseURL); err != nil { + ps.logf(" ⚠️ ntfy config warning: %v", err) + } else { + ps.logf(" ✓ ntfy config generated (base_url: %s)", ntfyBaseURL) + } + } + if err := ps.binaryInstaller.ConfigureCaddy(caddyDomain, email, acmeEndpoint, baseDomain); err != nil { ps.logf(" ⚠️ Caddy config warning: %v", err) } else { @@ -859,6 +899,13 @@ func (ps *ProductionSetup) Phase5CreateSystemdServices(enableHTTPS bool) error { if _, err := os.Stat("/usr/bin/caddy"); err == nil { services = append(services, "caddy.service") } + // Add ntfy when this node hosts the self-hosted ntfy server (#72). + // The unit file is written by installers/ntfy.go::writeSystemdUnit. + if ps.isNtfyHost { + if _, err := os.Stat("/usr/local/bin/ntfy"); err == nil { + services = append(services, "ntfy.service") + } + } for _, svc := range services { if err := ps.serviceController.EnableService(svc); err != nil { ps.logf(" ⚠️ Failed to enable %s: %v", svc, err) @@ -935,6 +982,19 @@ func (ps *ProductionSetup) Phase5CreateSystemdServices(enableHTTPS bool) error { } } + // Start ntfy when this node hosts it (#72). Caddy must already be + // up (it terminates TLS for push.), which the order + // above guarantees. + if ps.isNtfyHost { + if _, err := os.Stat("/usr/local/bin/ntfy"); err == nil { + if err := ps.serviceController.RestartService("ntfy.service"); err != nil { + ps.logf(" ⚠️ Failed to start ntfy.service: %v", err) + } else { + ps.logf(" - ntfy.service started") + } + } + } + ps.logf(" ✓ All services started") return nil } diff --git a/core/pkg/environments/production/preferences.go b/core/pkg/environments/production/preferences.go index 38da5d5..554800f 100644 --- a/core/pkg/environments/production/preferences.go +++ b/core/pkg/environments/production/preferences.go @@ -11,6 +11,7 @@ import ( type NodePreferences struct { Branch string `yaml:"branch"` Nameserver bool `yaml:"nameserver"` + NtfyHost bool `yaml:"ntfy_host"` // Feature #72: this node hosts self-hosted ntfy AnyoneClient bool `yaml:"anyone_client"` AnyoneRelay bool `yaml:"anyone_relay"` AnyoneORPort int `yaml:"anyone_orport,omitempty"` // typically 9001 diff --git a/core/pkg/gateway/dependencies.go b/core/pkg/gateway/dependencies.go index d1eba09..b0f4081 100644 --- a/core/pkg/gateway/dependencies.go +++ b/core/pkg/gateway/dependencies.go @@ -20,6 +20,8 @@ import ( "github.com/DeBrosOfficial/network/pkg/olric" "github.com/DeBrosOfficial/network/pkg/pubsub" "github.com/DeBrosOfficial/network/pkg/push" + pushcreds "github.com/DeBrosOfficial/network/pkg/push/credentials" + pushapns "github.com/DeBrosOfficial/network/pkg/push/providers/apns" pushexpo "github.com/DeBrosOfficial/network/pkg/push/providers/expo" pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy" "github.com/DeBrosOfficial/network/pkg/rqlite" @@ -96,6 +98,13 @@ type Dependencies struct { PushManager *push.Manager PushConfigStore push.ConfigStore + // PushCredentialsManager owns per-namespace, per-provider push + // credentials (feature #72). Used by provider factories to look up + // the right credential at send time, and by the HTTP credentials + // handlers for tenant self-service PUT/GET/DELETE. Nil when the + // cluster secret is unavailable. + PushCredentialsManager *pushcreds.Manager + // Authentication service AuthService *auth.Service } @@ -480,7 +489,7 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe // // PushDispatcher (legacy) is set only when YAML defaults exist — // kept for back-compat with code that hasn't migrated to Manager. - pushDispatcher, pushStore, pushManager, pushCfgStore, err := buildPushDispatcher(cfg, deps.ORMClient, logger) + pushDispatcher, pushStore, pushManager, pushCfgStore, pushCredManager, err := buildPushDispatcher(cfg, deps.ORMClient, logger) if err != nil { // Non-fatal: log and continue. Functions calling push_send will get nil // (silent no-op) and HTTP /v1/push/* endpoints return 503. @@ -491,6 +500,7 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe deps.PushDeviceStore = pushStore deps.PushManager = pushManager deps.PushConfigStore = pushCfgStore + deps.PushCredentialsManager = pushCredManager // Create host functions provider (allows functions to call Orama services) hostFuncsCfg := hostfunctions.HostFunctionsConfig{ @@ -871,40 +881,109 @@ func buildPushDispatcher( cfg *Config, db rqlite.Client, logger *logging.ColoredLogger, -) (*push.PushDispatcher, push.PushDeviceStore, *push.Manager, push.ConfigStore, error) { +) (*push.PushDispatcher, push.PushDeviceStore, *push.Manager, push.ConfigStore, *pushcreds.Manager, error) { if cfg.ClusterSecret == "" { // Without the cluster secret we can't encrypt credentials at rest. // Disable the whole push subsystem; HTTP routes return 503. - return nil, nil, nil, nil, nil + return nil, nil, nil, nil, nil, nil } store, err := push.NewRqliteDeviceStore(db, cfg.ClusterSecret, logger.Logger) if err != nil { - return nil, nil, nil, nil, fmt.Errorf("init push device store: %w", err) + return nil, nil, nil, nil, nil, fmt.Errorf("init push device store: %w", err) } cfgStore, err := push.NewRqliteConfigStore(db, cfg.ClusterSecret, logger.Logger) if err != nil { - return nil, nil, nil, nil, fmt.Errorf("init push config store: %w", err) + return nil, nil, nil, nil, nil, fmt.Errorf("init push config store: %w", err) } + // Per-namespace, per-provider credentials (feature #72). Generic + // store — used by APNs, ntfy (post-migration), FCM-direct (future). + // Provider packages register their Validator at gateway startup + // (see pushcreds.Register calls below). + credStore, err := pushcreds.NewRqliteStore(db, cfg.ClusterSecret, logger.Logger) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("init push credentials store: %w", err) + } + credManager := pushcreds.NewManager(credStore, logger.Logger) + + // Register the Validators that this gateway accepts. Each provider + // package owns its own JSON schema + redactor; we tell the + // credentials package which ones to allow at PUT/GET time. Adding a + // new provider (FCM-direct, SMS, etc.) means a single new Register + // call here — no other code needs to know. + pushcreds.Register(pushapns.NewValidator()) + pushcreds.Register(pushntfy.NewValidator()) + // ProviderFactory turns a resolved Config into the right set of // provider instances. Lives here in dependencies.go because this is // the only place that imports both the manager package and the // concrete provider sub-packages — keeps push core dep-cycle-free. - factory := func(c push.Config) []push.PushProvider { + // + // Per-namespace credentialed providers (APNs — feature #72) are + // constructed here by consulting the credentials manager. If a + // namespace has stored credentials for a provider, that provider is + // instantiated with those credentials and registered in the + // dispatcher; otherwise it's omitted. + factory := func(ctx context.Context, c push.Config) []push.PushProvider { var ps []push.PushProvider - if c.NtfyBaseURL != "" { - ps = append(ps, pushntfy.New(pushntfy.Config{ - BaseURL: c.NtfyBaseURL, - AuthToken: c.NtfyAuthToken, - }, logger.Logger)) + + // ntfy provider — sourced from EITHER the new credentials store + // (#72, preferred) OR the legacy 026 push_config row. New table + // wins field-by-field; legacy fills any gap. ntfy is registered + // only if a BaseURL ends up set; auth_token alone is useless + // without a server to point at. + ntfyCfg := pushntfy.Config{ + BaseURL: c.NtfyBaseURL, + AuthToken: c.NtfyAuthToken, + } + if c.Namespace != "" && credManager != nil { + if cred, err := credManager.Get(ctx, c.Namespace, "ntfy"); err == nil && cred != nil { + if ov, perr := pushntfy.ParseCredentials(cred.JSON); perr == nil { + if ov.BaseURL != "" { + ntfyCfg.BaseURL = ov.BaseURL + } + if ov.AuthToken != "" { + ntfyCfg.AuthToken = ov.AuthToken + } + } else { + logger.ComponentWarn(logging.ComponentGeneral, + "ntfy credentials parse failed", + zap.String("namespace", c.Namespace), + zap.Error(perr)) + } + } + } + if ntfyCfg.BaseURL != "" { + ps = append(ps, pushntfy.New(ntfyCfg, logger.Logger)) } if c.ExpoAccessToken != "" { ps = append(ps, pushexpo.New(pushexpo.Config{ AccessToken: c.ExpoAccessToken, }, logger.Logger)) } + // APNs is fully credentialed — no YAML fallback. The presence of + // per-namespace credentials is the trigger. + if c.Namespace != "" && credManager != nil { + if cred, err := credManager.Get(ctx, c.Namespace, "apns"); err == nil && cred != nil { + if apnsCfg, perr := pushapns.ParseCredentials(cred.JSON); perr == nil { + if provider, nerr := pushapns.New(apnsCfg, logger.Logger); nerr == nil { + ps = append(ps, provider) + } else { + logger.ComponentWarn(logging.ComponentGeneral, + "apns provider construction failed", + zap.String("namespace", c.Namespace), + zap.Error(nerr)) + } + } else { + logger.ComponentWarn(logging.ComponentGeneral, + "apns credentials parse failed", + zap.String("namespace", c.Namespace), + zap.Error(perr)) + } + } + } return ps } @@ -922,7 +1001,10 @@ func buildPushDispatcher( var legacy *push.PushDispatcher if !defaults.IsEmpty() { legacy = push.New(store, logger.Logger) - for _, p := range factory(push.Config{ + // Boot-time construction: no request context yet. Use Background + // — the credential lookups here are fast (in-memory cache miss + // reads rqlite once) and cancellation is irrelevant during boot. + for _, p := range factory(context.Background(), push.Config{ NtfyBaseURL: defaults.NtfyBaseURL, NtfyAuthToken: defaults.NtfyAuthToken, ExpoAccessToken: defaults.ExpoAccessToken, @@ -941,5 +1023,5 @@ func buildPushDispatcher( logger.ComponentInfo(logging.ComponentGeneral, "push subsystem initialized; tenants can self-serve via PUT /v1/push/config") - return legacy, store, manager, cfgStore, nil + return legacy, store, manager, cfgStore, credManager, nil } diff --git a/core/pkg/gateway/gateway.go b/core/pkg/gateway/gateway.go index 976d424..16bd66e 100644 --- a/core/pkg/gateway/gateway.go +++ b/core/pkg/gateway/gateway.go @@ -391,6 +391,12 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { } else if deps.PushDispatcher != nil { gw.pushHandlers = pushhandlers.NewHandlers(deps.PushDispatcher, deps.PushDeviceStore, logger) } + // Wire the per-provider credentials manager (feature #72) if push is + // up. The handler nil-checks the manager internally so this is safe + // even when push is partially configured. + if gw.pushHandlers != nil && deps.PushCredentialsManager != nil { + gw.pushHandlers.SetCredentialsManager(deps.PushCredentialsManager) + } if cfg.WebRTCEnabled && cfg.SFUPort > 0 { gw.webrtcHandlers = webrtchandlers.NewWebRTCHandlers( diff --git a/core/pkg/gateway/handlers/push/credentials_handler.go b/core/pkg/gateway/handlers/push/credentials_handler.go new file mode 100644 index 0000000..bdac2b7 --- /dev/null +++ b/core/pkg/gateway/handlers/push/credentials_handler.go @@ -0,0 +1,341 @@ +package push + +// credentials_handler.go — tenant-self-service per-provider push +// credentials. Feature #72. +// +// Endpoints (mounted under /v1/namespace/push-credentials/{provider}): +// +// GET /v1/namespace/push-credentials → summary: which providers are configured +// GET /v1/namespace/push-credentials/{provider} → provider-specific redacted view +// PUT /v1/namespace/push-credentials/{provider} → validate + store (any JSON schema, owned by provider) +// DELETE /v1/namespace/push-credentials/{provider} → clear +// +// The handler itself is GENERIC: it never reads the credential JSON +// schema. Validation + redaction are delegated to the provider's +// Validator (registered at gateway startup). Adding a new provider — +// FCM, SMS, anything — requires zero changes to this file. +// +// Auth model: same as /v1/push/config (the existing PutConfigHandler). +// The caller must be JWT-authenticated; their namespace is resolved by +// the upstream middleware. API-key-only callers are rejected because +// credential changes are operator-level mutations. + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/push/credentials" + + "go.uber.org/zap" +) + +// MaxCredentialsBodyBytes caps the PUT body size. p8 keys + Apple Team +// ID + Key ID + Bundle ID + JSON overhead fit comfortably under 16 KB. +// FCM service-account JSON tops out around 2 KB. 32 KB is generous and +// safely rejects absurd payloads. +const MaxCredentialsBodyBytes = 32 * 1024 + +// pathPrefixCredentials is the URL prefix this handler dispatches under. +// The trailing segment (if present) is the provider name; an absent +// segment selects the summary view. +const pathPrefixCredentials = "/v1/namespace/push-credentials" + +// SetCredentialsManager wires the per-provider credential manager into +// the handlers. Called from the gateway dependency wiring; nil-safe +// (the handler returns 503 when the manager is absent, same shape as +// the other "subsystem not configured" 503s). +func (h *Handlers) SetCredentialsManager(m *credentials.Manager) { + h.credentialsManager = m +} + +// invalidatePushDispatcher is called after a successful PUT/DELETE on +// /v1/namespace/push-credentials/{provider} so the push.Manager +// rebuilds the namespace's dispatcher with the new credentials. This +// MUST be called in addition to credentialsManager.Invalidate — +// dropping the credential-cache entry alone isn't enough; the push +// dispatcher already holds an APNs/ntfy provider constructed from the +// old creds, and it stays in the dispatcher cache until the next TTL +// rebuild. +// +// nil-safe: if push.Manager isn't wired (e.g. cluster secret missing), +// this is a no-op. +func (h *Handlers) invalidatePushDispatcher(namespace string) { + if h.manager != nil { + h.manager.Invalidate(namespace) + } +} + +// CredentialsSummary is the GET (no provider) response shape. +// +// `Configured` is the list of provider names that have a stored +// credential row. `Supported` is the list of providers this gateway +// can accept PUTs for (i.e. has a registered Validator). Their +// intersection is "what's effective right now"; `Supported` minus +// `Configured` is "what the tenant could enable next". +type CredentialsSummary struct { + Namespace string `json:"namespace"` + Configured []string `json:"configured"` + Supported []string `json:"supported"` +} + +// CredentialsSummaryHandler — GET /v1/namespace/push-credentials. +// Returns the list of providers that have a credential row for the +// namespace, plus the list of providers this gateway supports. +func (h *Handlers) CredentialsSummaryHandler(w http.ResponseWriter, r *http.Request) { + if h.credentialsManager == nil { + writeError(w, http.StatusServiceUnavailable, + "push credentials not available on this gateway") + return + } + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + ns := resolveNamespace(r) + if ns == "" { + writeError(w, http.StatusForbidden, "namespace not resolved") + return + } + configured, err := h.credentialsManager.Store().ListProviders(boundCtx(r), ns) + if err != nil { + h.logger.ComponentWarn("push", "credentials summary failed", + zap.String("namespace", ns), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to list configured providers") + return + } + // Stable shape: never return `null` for the array fields. + if configured == nil { + configured = []string{} + } + supported := credentials.RegisteredProviders() + if supported == nil { + supported = []string{} + } + writeJSON(w, http.StatusOK, CredentialsSummary{ + Namespace: ns, + Configured: configured, + Supported: supported, + }) +} + +// CredentialsByProviderHandler — GET/PUT/DELETE on +// /v1/namespace/push-credentials/{provider}. +// +// Dispatches by method. `{provider}` is extracted from the URL path; +// unknown providers return 400 (clearer than 404 — they ARE valid +// resource shapes, just not enabled on this gateway). +func (h *Handlers) CredentialsByProviderHandler(w http.ResponseWriter, r *http.Request) { + if h.credentialsManager == nil { + writeError(w, http.StatusServiceUnavailable, + "push credentials not available on this gateway") + return + } + ns := resolveNamespace(r) + if ns == "" { + writeError(w, http.StatusForbidden, "namespace not resolved") + return + } + provider := extractProvider(r.URL.Path) + if provider == "" { + writeError(w, http.StatusBadRequest, + "provider required in path: /v1/namespace/push-credentials/{provider}") + return + } + v, ok := credentials.LookupValidator(provider) + if !ok { + writeError(w, http.StatusBadRequest, + "unsupported provider: "+provider+ + " (supported: "+strings.Join(credentials.RegisteredProviders(), ", ")+")") + return + } + + switch r.Method { + case http.MethodGet: + h.getCredentials(w, r, ns, provider, v) + case http.MethodPut, http.MethodPost: + h.putCredentials(w, r, ns, provider, v) + case http.MethodDelete: + h.deleteCredentials(w, r, ns, provider) + default: + writeError(w, http.StatusMethodNotAllowed, + "method not allowed: use GET to read, PUT to update, or DELETE to clear") + } +} + +// getCredentials returns the redacted view of the provider's credential +// for the namespace, or an empty body with `configured: false` if no +// credential is stored. +func (h *Handlers) getCredentials( + w http.ResponseWriter, r *http.Request, + ns, provider string, v credentials.Validator, +) { + cred, err := h.credentialsManager.Get(boundCtx(r), ns, provider) + if err != nil { + h.logger.ComponentWarn("push", "credentials GET failed", + zap.String("namespace", ns), + zap.String("provider", provider), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to load credential") + return + } + if cred == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "namespace": ns, + "provider": provider, + "configured": false, + }) + return + } + redacted, err := v.Redact(cred.JSON) + if err != nil { + h.logger.ComponentWarn("push", "credentials redact failed", + zap.String("namespace", ns), + zap.String("provider", provider), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to redact credential") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "namespace": ns, + "provider": provider, + "configured": true, + "updated_at": cred.UpdatedAt, + "updated_by": cred.UpdatedBy, + "redacted": redacted, + }) +} + +// putCredentials validates the body against the provider's schema and +// stores the encrypted blob. Body is the provider-specific JSON +// document — the handler does not inspect its fields. +func (h *Handlers) putCredentials( + w http.ResponseWriter, r *http.Request, + ns, provider string, v credentials.Validator, +) { + caller := resolveCallerUserID(r) + if caller == "" { + writeError(w, http.StatusUnauthorized, "user authentication required (JWT)") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, MaxCredentialsBodyBytes) + raw, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read body: "+err.Error()) + return + } + if len(raw) == 0 { + writeError(w, http.StatusBadRequest, "empty body; expected JSON") + return + } + // Lightweight syntactic check before handing to the Validator. Cheap + // and lets us return a clearer "not JSON" message than a custom + // per-provider parse error. + if !json.Valid(raw) { + writeError(w, http.StatusBadRequest, "body is not valid JSON") + return + } + if err := v.Validate(raw); err != nil { + writeError(w, http.StatusBadRequest, "credential validation failed: "+err.Error()) + return + } + + cred := credentials.Credential{ + Namespace: ns, + Provider: provider, + JSON: raw, + UpdatedAt: time.Now().Unix(), + UpdatedBy: caller, + } + if err := h.credentialsManager.Store().Upsert(boundCtx(r), cred); err != nil { + h.logger.ComponentWarn("push", "credentials PUT failed", + zap.String("namespace", ns), + zap.String("provider", provider), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to save credential") + return + } + // Drop BOTH caches: the credential-store cache (so the next Get + // reads the new blob) AND the push.Manager dispatcher cache (so + // the next SendToUser rebuilds with a provider constructed from + // the new credentials). Missing the second invalidate was a real + // bug — APNs key rotations would never take effect on the rotating + // gateway until LRU eviction. Other gateways still rely on the + // push.Manager's TTL for propagation. + h.credentialsManager.Invalidate(ns, provider) + h.invalidatePushDispatcher(ns) + h.logger.ComponentInfo("push", "credentials updated", + zap.String("namespace", ns), + zap.String("provider", provider), + zap.String("updated_by", caller)) + + redacted, redactErr := v.Redact(raw) + if redactErr != nil { + // Storage succeeded but the response can't safely include the + // redacted view. Log it and return success with a minimal body + // — never leak the raw credential as a fallback. + h.logger.ComponentWarn("push", "credentials redact failed post-PUT", + zap.String("namespace", ns), + zap.String("provider", provider), zap.Error(redactErr)) + redacted = map[string]interface{}{"redact_error": "see server logs"} + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "namespace": ns, + "provider": provider, + "configured": true, + "updated_at": cred.UpdatedAt, + "updated_by": cred.UpdatedBy, + "redacted": redacted, + }) +} + +// deleteCredentials clears the provider's credential row for the +// namespace. Idempotent — returns 200 even if no row existed, so +// callers can DELETE freely. +func (h *Handlers) deleteCredentials( + w http.ResponseWriter, r *http.Request, + ns, provider string, +) { + caller := resolveCallerUserID(r) + if caller == "" { + writeError(w, http.StatusUnauthorized, "user authentication required (JWT)") + return + } + if err := h.credentialsManager.Store().Delete(boundCtx(r), ns, provider); err != nil { + h.logger.ComponentWarn("push", "credentials DELETE failed", + zap.String("namespace", ns), + zap.String("provider", provider), zap.Error(err)) + writeError(w, http.StatusInternalServerError, "failed to delete credential") + return + } + // Same dual-cache invalidation as PUT — see putCredentials. + h.credentialsManager.Invalidate(ns, provider) + h.invalidatePushDispatcher(ns) + h.logger.ComponentInfo("push", "credentials cleared", + zap.String("namespace", ns), + zap.String("provider", provider), + zap.String("cleared_by", caller)) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "namespace": ns, + "provider": provider, + "configured": false, + }) +} + +// extractProvider returns the provider segment after pathPrefixCredentials, +// or empty if absent. +func extractProvider(urlPath string) string { + if !strings.HasPrefix(urlPath, pathPrefixCredentials) { + return "" + } + rest := strings.TrimPrefix(urlPath, pathPrefixCredentials) + rest = strings.TrimPrefix(rest, "/") + if rest == "" { + return "" + } + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + rest = rest[:i] + } + return rest +} + diff --git a/core/pkg/gateway/handlers/push/credentials_handler_test.go b/core/pkg/gateway/handlers/push/credentials_handler_test.go new file mode 100644 index 0000000..665ed37 --- /dev/null +++ b/core/pkg/gateway/handlers/push/credentials_handler_test.go @@ -0,0 +1,380 @@ +package push + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DeBrosOfficial/network/pkg/gateway/auth" + "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" + "github.com/DeBrosOfficial/network/pkg/logging" + "github.com/DeBrosOfficial/network/pkg/push/credentials" +) + +// fakeStore satisfies credentials.Store with an in-memory map. Mirrors +// the manager_test.go fake but locally typed because the package can't +// import credentials' internal fakeStore. +type fakeCredStore struct { + rows map[string]*credentials.Credential // key: namespace+"|"+provider +} + +func newFakeCredStore() *fakeCredStore { + return &fakeCredStore{rows: map[string]*credentials.Credential{}} +} +func key(ns, p string) string { return ns + "|" + p } + +func (f *fakeCredStore) Get(_ context.Context, ns, p string) (*credentials.Credential, error) { + if c, ok := f.rows[key(ns, p)]; ok { + cp := *c + return &cp, nil + } + return nil, credentials.ErrNotFound +} +func (f *fakeCredStore) Upsert(_ context.Context, c credentials.Credential) error { + cp := c + f.rows[key(c.Namespace, c.Provider)] = &cp + return nil +} +func (f *fakeCredStore) Delete(_ context.Context, ns, p string) error { + delete(f.rows, key(ns, p)) + return nil +} +func (f *fakeCredStore) ListProviders(_ context.Context, ns string) ([]string, error) { + var out []string + for k, c := range f.rows { + if strings.HasPrefix(k, ns+"|") { + out = append(out, c.Provider) + } + } + return out, nil +} + +// fakeValidator records validate/redact calls and lets tests inject +// validation errors. +type fakeValidator struct { + name string + validate func([]byte) error + redact func([]byte) (interface{}, error) +} + +func (v fakeValidator) Provider() string { return v.name } +func (v fakeValidator) Validate(b []byte) error { + if v.validate != nil { + return v.validate(b) + } + return nil +} +func (v fakeValidator) Redact(b []byte) (interface{}, error) { + if v.redact != nil { + return v.redact(b) + } + // Default: return a map with `has_` for every top-level + // key. Good enough for round-trip tests. + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + out := map[string]interface{}{} + for k := range raw { + out["has_"+k] = true + } + return out, nil +} + +// buildHandlersWithCreds wires Handlers with only the credentials path +// populated. Auth context (namespace + JWT subject) is set on the test +// request directly. +func buildHandlersWithCreds(t *testing.T) (*Handlers, *fakeCredStore) { + t.Helper() + logger, _ := logging.NewColoredLogger(logging.ComponentGeneral, false) + h := &Handlers{logger: logger} + store := newFakeCredStore() + h.SetCredentialsManager(credentials.NewManager(store, nil)) + return h, store +} + +// authedRequest builds a request with namespace + JWT subject in context, +// matching what the upstream auth middleware does in production. +func authedRequest(method, target string, body []byte, ns, sub string) *http.Request { + var r *http.Request + if body != nil { + r = httptest.NewRequest(method, target, bytes.NewReader(body)) + } else { + r = httptest.NewRequest(method, target, nil) + } + ctx := r.Context() + if ns != "" { + ctx = context.WithValue(ctx, ctxkeys.NamespaceOverride, ns) + } + if sub != "" { + ctx = context.WithValue(ctx, ctxkeys.JWT, &auth.JWTClaims{Sub: sub}) + } + return r.WithContext(ctx) +} + +func TestCredentials_PutGetRoundTrip(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, store := buildHandlersWithCreds(t) + + // PUT a credential. + body := []byte(`{"team_id":"ABCD1234","key_id":"XYZ","p8_key":"-----BEGIN..."}`) + r := authedRequest(http.MethodPut, + "/v1/namespace/push-credentials/apns", body, "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body=%s", w.Code, w.Body.String()) + } + + // Stored value should be the verbatim JSON. + if got := store.rows[key("ns-a", "apns")]; got == nil { + t.Fatal("PUT did not persist credential") + } else if !bytes.Equal(got.JSON, body) { + t.Errorf("stored JSON differs:\n got: %s\nwant: %s", got.JSON, body) + } + + // GET returns redacted view + audit fields. + r = authedRequest(http.MethodGet, "/v1/namespace/push-credentials/apns", nil, "ns-a", "wallet-1") + w = httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusOK { + t.Fatalf("GET status = %d, body=%s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode GET: %v", err) + } + if resp["configured"] != true { + t.Errorf("GET should report configured=true; got %v", resp["configured"]) + } + // Redacted view shouldn't echo any of the secret strings. + bodyStr := w.Body.String() + if strings.Contains(bodyStr, "BEGIN") || strings.Contains(bodyStr, "ABCD1234") { + t.Errorf("redacted GET leaked secret material: %s", bodyStr) + } +} + +func TestCredentials_PutRejectsBadJSON(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, _ := buildHandlersWithCreds(t) + r := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/apns", + []byte(`{not json}`), "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for malformed JSON; got %d (body=%s)", w.Code, w.Body.String()) + } +} + +func TestCredentials_PutEmptyBodyRejected(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, _ := buildHandlersWithCreds(t) + r := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/apns", + nil, "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for empty body; got %d", w.Code) + } +} + +func TestCredentials_PutValidatorErrorPropagates(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{ + name: "apns", + validate: func(_ []byte) error { + return errors.New("missing team_id") + }, + }) + + h, store := buildHandlersWithCreds(t) + r := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/apns", + []byte(`{}`), "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 on validator failure; got %d (body=%s)", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "missing team_id") { + t.Errorf("validator error not surfaced to client: %s", w.Body.String()) + } + // Validator rejection must NOT persist. + if _, ok := store.rows[key("ns-a", "apns")]; ok { + t.Error("rejected PUT should not have persisted") + } +} + +func TestCredentials_UnknownProviderRejected(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, _ := buildHandlersWithCreds(t) + r := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/sms", + []byte(`{}`), "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unregistered provider; got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "unsupported provider") { + t.Errorf("error message should explain unsupported provider: %s", w.Body.String()) + } +} + +func TestCredentials_DeleteIdempotent(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, _ := buildHandlersWithCreds(t) + + // Delete with no row should still succeed. + r := authedRequest(http.MethodDelete, "/v1/namespace/push-credentials/apns", + nil, "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusOK { + t.Errorf("DELETE no-row: status %d (body=%s)", w.Code, w.Body.String()) + } + + // PUT then DELETE clears. + put := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/apns", + []byte(`{"x":1}`), "ns-a", "wallet-1") + h.CredentialsByProviderHandler(httptest.NewRecorder(), put) + + r = authedRequest(http.MethodDelete, "/v1/namespace/push-credentials/apns", + nil, "ns-a", "wallet-1") + w = httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusOK { + t.Errorf("DELETE existing: status %d", w.Code) + } + + // Re-GET should report not configured. + r = authedRequest(http.MethodGet, "/v1/namespace/push-credentials/apns", + nil, "ns-a", "wallet-1") + w = httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusOK { + t.Fatalf("post-delete GET: %d", w.Code) + } + var resp map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["configured"] != false { + t.Errorf("post-delete GET should report configured=false; got %+v", resp) + } +} + +func TestCredentials_MissingAuthRejected(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, _ := buildHandlersWithCreds(t) + + // PUT without JWT subject — 401. + r := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/apns", + []byte(`{}`), "ns-a", "" /* no JWT */) + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusUnauthorized { + t.Errorf("PUT no-JWT: status %d", w.Code) + } +} + +func TestCredentials_MissingNamespaceRejected(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + + h, _ := buildHandlersWithCreds(t) + r := authedRequest(http.MethodGet, "/v1/namespace/push-credentials/apns", + nil, "" /* no ns */, "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("GET no-ns: status %d", w.Code) + } +} + +func TestCredentials_SummaryReportsConfiguredAndSupported(t *testing.T) { + credentials.ResetRegistryForTest() + defer credentials.ResetRegistryForTest() + credentials.Register(fakeValidator{name: "apns"}) + credentials.Register(fakeValidator{name: "ntfy"}) + credentials.Register(fakeValidator{name: "fcm"}) + + h, _ := buildHandlersWithCreds(t) + + // Configure apns only. + put := authedRequest(http.MethodPut, "/v1/namespace/push-credentials/apns", + []byte(`{"x":1}`), "ns-a", "wallet-1") + h.CredentialsByProviderHandler(httptest.NewRecorder(), put) + + r := authedRequest(http.MethodGet, "/v1/namespace/push-credentials", nil, "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsSummaryHandler(w, r) + if w.Code != http.StatusOK { + t.Fatalf("summary: %d (body=%s)", w.Code, w.Body.String()) + } + var resp CredentialsSummary + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode summary: %v", err) + } + if resp.Namespace != "ns-a" { + t.Errorf("namespace=%q want ns-a", resp.Namespace) + } + if len(resp.Configured) != 1 || resp.Configured[0] != "apns" { + t.Errorf("configured=%v want [apns]", resp.Configured) + } + if len(resp.Supported) != 3 { + t.Errorf("supported=%v want 3 entries", resp.Supported) + } +} + +func TestCredentials_NoManagerReturns503(t *testing.T) { + logger, _ := logging.NewColoredLogger(logging.ComponentGeneral, false) + h := &Handlers{logger: logger} // no credentialsManager + r := authedRequest(http.MethodGet, "/v1/namespace/push-credentials/apns", nil, "ns-a", "wallet-1") + w := httptest.NewRecorder() + h.CredentialsByProviderHandler(w, r) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503 when manager nil; got %d", w.Code) + } +} + +func TestExtractProvider(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/v1/namespace/push-credentials/apns", "apns"}, + {"/v1/namespace/push-credentials/apns/", "apns"}, + {"/v1/namespace/push-credentials/apns?foo=bar", "apns"}, + {"/v1/namespace/push-credentials/", ""}, + {"/v1/namespace/push-credentials", ""}, + {"/some/other/path", ""}, + {"/v1/namespace/push-credentials/n-t.f_y", "n-t.f_y"}, + } + for _, tt := range tests { + if got := extractProvider(tt.path); got != tt.want { + t.Errorf("extractProvider(%q) = %q; want %q", tt.path, got, tt.want) + } + } +} diff --git a/core/pkg/gateway/handlers/push/types.go b/core/pkg/gateway/handlers/push/types.go index da66337..5fe2af0 100644 --- a/core/pkg/gateway/handlers/push/types.go +++ b/core/pkg/gateway/handlers/push/types.go @@ -22,6 +22,7 @@ import ( "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" "github.com/DeBrosOfficial/network/pkg/logging" "github.com/DeBrosOfficial/network/pkg/push" + "github.com/DeBrosOfficial/network/pkg/push/credentials" ) // Handlers serves the /v1/push/* HTTP endpoints. Construct via NewHandlers; @@ -36,11 +37,12 @@ import ( // configStore + manager may be nil on gateways with push fully disabled — // the corresponding endpoints return 503. type Handlers struct { - dispatcher *push.PushDispatcher - manager *push.Manager - store push.PushDeviceStore - configStore push.ConfigStore - logger *logging.ColoredLogger + dispatcher *push.PushDispatcher + manager *push.Manager + store push.PushDeviceStore + configStore push.ConfigStore + credentialsManager *credentials.Manager // optional — feature #72 (set via SetCredentialsManager) + logger *logging.ColoredLogger } // NewHandlers constructs a Handlers with the legacy single-namespace diff --git a/core/pkg/gateway/push_routes.go b/core/pkg/gateway/push_routes.go index f0a81b6..baecf78 100644 --- a/core/pkg/gateway/push_routes.go +++ b/core/pkg/gateway/push_routes.go @@ -86,3 +86,30 @@ func (g *Gateway) pushConfigHandler(w http.ResponseWriter, r *http.Request) { "method not allowed: use GET to read, PUT to update, or DELETE to clear") } } + +// pushCredentialsSummaryHandler — GET /v1/namespace/push-credentials. +// Returns the list of providers with credentials stored AND the list of +// providers this gateway supports (feature #72). 503 when push isn't +// configured at all. +func (g *Gateway) pushCredentialsSummaryHandler(w http.ResponseWriter, r *http.Request) { + if g.pushHandlers == nil { + httputil.WriteRPCError(w, http.StatusServiceUnavailable, + httputil.ErrCodeServiceUnavailable, pushNotConfiguredMessage) + return + } + g.pushHandlers.CredentialsSummaryHandler(w, r) +} + +// pushCredentialsByProviderHandler dispatches GET / PUT / DELETE on +// /v1/namespace/push-credentials/{provider} (feature #72 — generic +// per-provider credential storage). The {provider} segment is parsed +// inside the handler so unknown providers return a 400 with the list +// of supported ones rather than a bare 404. +func (g *Gateway) pushCredentialsByProviderHandler(w http.ResponseWriter, r *http.Request) { + if g.pushHandlers == nil { + httputil.WriteRPCError(w, http.StatusServiceUnavailable, + httputil.ErrCodeServiceUnavailable, pushNotConfiguredMessage) + return + } + g.pushHandlers.CredentialsByProviderHandler(w, r) +} diff --git a/core/pkg/gateway/routes.go b/core/pkg/gateway/routes.go index 3bdb96a..31be800 100644 --- a/core/pkg/gateway/routes.go +++ b/core/pkg/gateway/routes.go @@ -144,6 +144,16 @@ func (g *Gateway) Routes() http.Handler { // instead of filing an ops ticket. Method dispatched in the handler. mux.HandleFunc("/v1/push/config", g.pushConfigHandler) + // Per-namespace, per-provider push credentials (feature #72 — + // full-privacy push with APNs-direct + self-hosted ntfy). Generic by + // design: any provider with a registered Validator plugs in here + // without changes. Method + provider segment dispatched in the handler. + // + // Summary endpoint (no provider segment) returns "what's configured" + // + "what's supported" in one round trip. + mux.HandleFunc("/v1/namespace/push-credentials", g.pushCredentialsSummaryHandler) + mux.HandleFunc("/v1/namespace/push-credentials/", g.pushCredentialsByProviderHandler) + // Per-namespace rate-limit configuration (feature #69). // GET / PUT / DELETE — tenants self-serve their gateway-level rate // limit override (requests_per_minute, burst) up to an operator-set diff --git a/core/pkg/push/credentials/manager.go b/core/pkg/push/credentials/manager.go new file mode 100644 index 0000000..ef67f80 --- /dev/null +++ b/core/pkg/push/credentials/manager.go @@ -0,0 +1,181 @@ +package credentials + +import ( + "container/list" + "context" + "errors" + "sync" + "time" + + "go.uber.org/zap" +) + +// Manager is the read-side entry point for per-namespace, per-provider +// credentials. Provider packages call Manager.Get to load credentials +// at push-send time; the LRU+TTL cache eliminates per-call decryption +// for the (almost always) cache-hit path. +// +// Cache invalidation (defense in depth): +// +// - Immediate (this-gateway): the HTTP handler calls Invalidate(ns, +// provider) after PUT/DELETE so the next lookup on THIS gateway +// rebuilds from store. +// - Bounded staleness (cluster-wide): every cached entry expires +// after cacheEntryTTL (30s) and is reloaded from the store on the +// next call. Bounds the window during which a config change on +// gateway A is invisible to gateway B without requiring a pub/sub +// broadcast layer. Same model as pkg/ratelimit. +// +// Safe for concurrent use. +type Manager struct { + store Store + logger *zap.Logger + ttl time.Duration // configurable for tests; defaults to cacheEntryTTL + + mu sync.Mutex + cache map[cacheKey]*list.Element + lru *list.List + cacheCap int +} + +// cacheKey is (namespace, provider) — the natural primary key. +type cacheKey struct { + namespace string + provider string +} + +// cacheEntry is the LRU node payload. +type cacheEntry struct { + key cacheKey + cred *Credential // nil means "no row" (negative cache) + builtAt time.Time +} + +// NewManager constructs a Manager backed by the given store. +func NewManager(store Store, logger *zap.Logger) *Manager { + if logger == nil { + logger = zap.NewNop() + } + return &Manager{ + store: store, + logger: logger, + ttl: cacheEntryTTL, + cache: make(map[cacheKey]*list.Element, defaultCacheCap), + lru: list.New(), + cacheCap: defaultCacheCap, + } +} + +// SetCacheTTL overrides the default cache-entry TTL. Intended for tests +// (where 30s is too long to wait) and for operators who want a tighter +// propagation window across multi-gateway deployments. A non-positive +// argument is a no-op. +func (m *Manager) SetCacheTTL(d time.Duration) { + if d <= 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.ttl = d +} + +// Get returns the credential for (namespace, provider) or (nil, nil) if +// no credential is configured. A store error is returned to the caller +// — unlike rate limiting (where we fail open under a store error), a +// missing push credential MUST surface so the caller doesn't silently +// drop a message to a misconfigured provider. +func (m *Manager) Get(ctx context.Context, namespace, provider string) (*Credential, error) { + if namespace == "" { + return nil, ErrInvalidNamespace + } + if provider == "" { + return nil, ErrInvalidProvider + } + key := cacheKey{namespace: namespace, provider: provider} + + m.mu.Lock() + if el, ok := m.cache[key]; ok { + entry := el.Value.(*cacheEntry) + if time.Since(entry.builtAt) < m.ttl { + m.lru.MoveToFront(el) + m.mu.Unlock() + return entry.cred, nil + } + // Expired — drop and fall through to rebuild. + m.lru.Remove(el) + delete(m.cache, key) + } + m.mu.Unlock() + + cred, err := m.store.Get(ctx, namespace, provider) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, err + } + // Store ErrNotFound → cache a negative (nil cred) entry so we don't + // hammer rqlite for "namespace doesn't use this provider" on the hot + // send path. The TTL still expires the negative entry, so once a + // tenant DOES configure the provider, latency to first-effective is + // bounded by the TTL. + + m.mu.Lock() + defer m.mu.Unlock() + + // Recheck under lock — another goroutine may have built one + // concurrently. Use it if it's still fresh. + if el, ok := m.cache[key]; ok { + entry := el.Value.(*cacheEntry) + if time.Since(entry.builtAt) < m.ttl { + m.lru.MoveToFront(el) + return entry.cred, nil + } + m.lru.Remove(el) + delete(m.cache, key) + } + + entry := &cacheEntry{key: key, cred: cred, builtAt: time.Now()} + el := m.lru.PushFront(entry) + m.cache[key] = el + for m.lru.Len() > m.cacheCap { + tail := m.lru.Back() + if tail == nil { + break + } + m.lru.Remove(tail) + delete(m.cache, tail.Value.(*cacheEntry).key) + } + return cred, nil +} + +// Invalidate evicts the cached entry for (namespace, provider). Called +// by the HTTP handler after PUT/DELETE so the next Get reloads from +// the store. +func (m *Manager) Invalidate(namespace, provider string) { + m.mu.Lock() + defer m.mu.Unlock() + key := cacheKey{namespace: namespace, provider: provider} + if el, ok := m.cache[key]; ok { + m.lru.Remove(el) + delete(m.cache, key) + } +} + +// InvalidateNamespace evicts every cached entry for the given namespace, +// regardless of provider. Used when a namespace is deleted wholesale or +// during an admin "rotate all credentials" operation. +func (m *Manager) InvalidateNamespace(namespace string) { + m.mu.Lock() + defer m.mu.Unlock() + for k, el := range m.cache { + if k.namespace == namespace { + m.lru.Remove(el) + delete(m.cache, k) + } + } +} + +// Store returns the underlying store. Used by the HTTP handlers for +// write paths (PUT/DELETE) which go straight to the store and then +// Invalidate; reads of cached state remain on the Manager. +func (m *Manager) Store() Store { + return m.store +} diff --git a/core/pkg/push/credentials/manager_test.go b/core/pkg/push/credentials/manager_test.go new file mode 100644 index 0000000..7d1dbdc --- /dev/null +++ b/core/pkg/push/credentials/manager_test.go @@ -0,0 +1,288 @@ +package credentials + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// fakeStore is an in-memory Store for unit tests. Tracks call counts so +// we can assert cache hits. +type fakeStore struct { + mu sync.Mutex + rows map[cacheKey]*Credential + getCount int + getErrOn cacheKey // if non-zero, Get returns errStub for this key + errStub error +} + +func newFakeStore() *fakeStore { + return &fakeStore{rows: map[cacheKey]*Credential{}} +} + +func (f *fakeStore) Get(_ context.Context, ns, p string) (*Credential, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.getCount++ + k := cacheKey{namespace: ns, provider: p} + if f.errStub != nil && f.getErrOn == k { + return nil, f.errStub + } + if c, ok := f.rows[k]; ok { + cp := *c + return &cp, nil + } + return nil, ErrNotFound +} + +func (f *fakeStore) Upsert(_ context.Context, c Credential) error { + f.mu.Lock() + defer f.mu.Unlock() + cp := c + f.rows[cacheKey{namespace: c.Namespace, provider: c.Provider}] = &cp + return nil +} + +func (f *fakeStore) Delete(_ context.Context, ns, p string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.rows, cacheKey{namespace: ns, provider: p}) + return nil +} + +func (f *fakeStore) ListProviders(_ context.Context, ns string) ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for k := range f.rows { + if k.namespace == ns { + out = append(out, k.provider) + } + } + return out, nil +} + +func TestManager_Get_cachesHit(t *testing.T) { + store := newFakeStore() + _ = store.Upsert(context.Background(), Credential{ + Namespace: "ns-a", Provider: "apns", JSON: []byte(`{"k":"v"}`), + }) + m := NewManager(store, nil) + + // First Get: store hit. + c1, err := m.Get(context.Background(), "ns-a", "apns") + if err != nil { + t.Fatalf("first Get: %v", err) + } + if c1 == nil || string(c1.JSON) != `{"k":"v"}` { + t.Fatalf("first Get returned wrong credential: %+v", c1) + } + if store.getCount != 1 { + t.Errorf("expected 1 store hit after first Get; got %d", store.getCount) + } + + // Second Get: should be served from cache. + if _, err := m.Get(context.Background(), "ns-a", "apns"); err != nil { + t.Fatalf("second Get: %v", err) + } + if store.getCount != 1 { + t.Errorf("expected cache hit; store.getCount=%d (should still be 1)", store.getCount) + } +} + +func TestManager_Get_negativeCachePreservedUntilTTL(t *testing.T) { + store := newFakeStore() + m := NewManager(store, nil) + m.SetCacheTTL(50 * time.Millisecond) + + // Namespace has no row — should cache the negative result. + c1, err := m.Get(context.Background(), "ns-a", "apns") + if err != nil { + t.Fatalf("Get: %v", err) + } + if c1 != nil { + t.Errorf("expected nil credential for not-found; got %+v", c1) + } + if store.getCount != 1 { + t.Errorf("expected 1 store hit; got %d", store.getCount) + } + + // Second Get within TTL: cached negative, no store hit. + c2, _ := m.Get(context.Background(), "ns-a", "apns") + if c2 != nil { + t.Errorf("expected nil cached credential; got %+v", c2) + } + if store.getCount != 1 { + t.Errorf("negative cache should suppress store hit; getCount=%d", store.getCount) + } +} + +func TestManager_Get_ttlForcesRebuild(t *testing.T) { + store := newFakeStore() + m := NewManager(store, nil) + m.SetCacheTTL(50 * time.Millisecond) + + // Initial: no row. + if _, err := m.Get(context.Background(), "ns-a", "apns"); err != nil { + t.Fatalf("first Get: %v", err) + } + if store.getCount != 1 { + t.Fatalf("expected 1; got %d", store.getCount) + } + + // Another gateway "writes" a row to the store directly (simulating + // the cross-gateway invalidation gap). + _ = store.Upsert(context.Background(), Credential{ + Namespace: "ns-a", Provider: "apns", JSON: []byte(`{"new":"value"}`), + }) + + // Within TTL: still cached negative. + c, _ := m.Get(context.Background(), "ns-a", "apns") + if c != nil { + t.Errorf("within TTL: expected stale-nil cache; got %+v", c) + } + + // Past TTL: rebuild reads the new row. + time.Sleep(80 * time.Millisecond) + c, err := m.Get(context.Background(), "ns-a", "apns") + if err != nil { + t.Fatalf("post-TTL Get: %v", err) + } + if c == nil || string(c.JSON) != `{"new":"value"}` { + t.Errorf("expected fresh cred after TTL; got %+v", c) + } +} + +func TestManager_Get_storeErrorSurfaces(t *testing.T) { + store := newFakeStore() + store.errStub = errors.New("rqlite connection refused") + store.getErrOn = cacheKey{namespace: "ns-a", provider: "apns"} + m := NewManager(store, nil) + + _, err := m.Get(context.Background(), "ns-a", "apns") + if err == nil { + t.Fatal("expected store error to bubble up; got nil") + } + if err.Error() != "rqlite connection refused" { + t.Errorf("wrong error wrapped/replaced: %v", err) + } +} + +func TestManager_Invalidate_evictsImmediately(t *testing.T) { + store := newFakeStore() + _ = store.Upsert(context.Background(), Credential{ + Namespace: "ns-a", Provider: "apns", JSON: []byte(`{"v":1}`), + }) + m := NewManager(store, nil) + + if _, err := m.Get(context.Background(), "ns-a", "apns"); err != nil { + t.Fatalf("warm Get: %v", err) + } + if store.getCount != 1 { + t.Fatalf("warm: %d", store.getCount) + } + + m.Invalidate("ns-a", "apns") + if _, err := m.Get(context.Background(), "ns-a", "apns"); err != nil { + t.Fatalf("post-invalidate Get: %v", err) + } + if store.getCount != 2 { + t.Errorf("expected store re-read after Invalidate; getCount=%d", store.getCount) + } +} + +func TestManager_InvalidateNamespace_evictsAllProviders(t *testing.T) { + store := newFakeStore() + _ = store.Upsert(context.Background(), Credential{ + Namespace: "ns-a", Provider: "apns", JSON: []byte(`{}`), + }) + _ = store.Upsert(context.Background(), Credential{ + Namespace: "ns-a", Provider: "ntfy", JSON: []byte(`{}`), + }) + m := NewManager(store, nil) + + _, _ = m.Get(context.Background(), "ns-a", "apns") + _, _ = m.Get(context.Background(), "ns-a", "ntfy") + if store.getCount != 2 { + t.Fatalf("warm: %d", store.getCount) + } + + m.InvalidateNamespace("ns-a") + _, _ = m.Get(context.Background(), "ns-a", "apns") + _, _ = m.Get(context.Background(), "ns-a", "ntfy") + if store.getCount != 4 { + t.Errorf("expected both providers re-read after namespace invalidate; getCount=%d", store.getCount) + } +} + +func TestManager_Get_rejectsEmptyInputs(t *testing.T) { + m := NewManager(newFakeStore(), nil) + if _, err := m.Get(context.Background(), "", "apns"); !errors.Is(err, ErrInvalidNamespace) { + t.Errorf("empty namespace: got %v, want ErrInvalidNamespace", err) + } + if _, err := m.Get(context.Background(), "ns-a", ""); !errors.Is(err, ErrInvalidProvider) { + t.Errorf("empty provider: got %v, want ErrInvalidProvider", err) + } +} + +func TestManager_Get_concurrentBuildsAreSafe(t *testing.T) { + // This test asserts CORRECTNESS under concurrency, not maximum + // store-hit reduction. The current implementation deliberately + // doesn't single-flight cold loads (no per-key mutex) — under a + // thundering herd, up to N goroutines can each hit the store + // before the first one populates the cache. That's an acceptable + // trade-off: the alternative (single-flight) adds complexity for + // a workload (credential lookups) where store hits are cheap + // (sub-ms) and contention is rare (cred changes are rare). + // + // What we verify here is: + // 1. No goroutine returns an error + // 2. Every goroutine sees the SAME credential (no torn reads) + // 3. After settle, the cache is populated (subsequent lookup + // should be 0 additional store hits) + store := newFakeStore() + _ = store.Upsert(context.Background(), Credential{ + Namespace: "ns-a", Provider: "apns", JSON: []byte(`{"k":"v"}`), + }) + m := NewManager(store, nil) + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + errs := make(chan error, n) + results := make(chan string, n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + c, err := m.Get(context.Background(), "ns-a", "apns") + if err != nil { + errs <- err + return + } + results <- string(c.JSON) + }() + } + wg.Wait() + close(errs) + close(results) + for err := range errs { + t.Errorf("concurrent Get failed: %v", err) + } + for got := range results { + if got != `{"k":"v"}` { + t.Errorf("torn read: got %q", got) + } + } + + // After settle, the cache MUST be populated — a fresh lookup hits + // no additional store reads. + before := store.getCount + if _, err := m.Get(context.Background(), "ns-a", "apns"); err != nil { + t.Fatalf("post-settle Get: %v", err) + } + if store.getCount != before { + t.Errorf("post-settle Get should be cache hit; before=%d after=%d", before, store.getCount) + } +} diff --git a/core/pkg/push/credentials/registry.go b/core/pkg/push/credentials/registry.go new file mode 100644 index 0000000..c92d4a7 --- /dev/null +++ b/core/pkg/push/credentials/registry.go @@ -0,0 +1,88 @@ +package credentials + +import "sync" + +// registry is the package-level map of provider name → Validator. +// +// Provider packages (pkg/push/providers/apns, .../ntfy, .../fcm, …) +// export a Validator implementation; the gateway dependency wiring +// calls Register at startup for each provider it wants to support on +// this gateway. Anyone-can-register-anything is intentional — operators +// who want to disable a provider simply don't register its Validator, +// and PUT/GET for that provider return 400 ErrUnknownProvider. +// +// Safe for concurrent reads; mutations should happen at gateway +// startup before request serving begins. +var ( + registryMu sync.RWMutex + registry = map[string]Validator{} +) + +// Register makes a Validator available for the provider name. Calling +// Register with the same name twice replaces the previous one — useful +// in tests; in production it indicates a wiring bug and is logged by +// the gateway startup path. +// +// Panics if v is nil or v.Provider() is empty: these are programmer +// errors that should fail loud at gateway startup, not mysteriously at +// first PUT. +func Register(v Validator) { + if v == nil { + panic("credentials: Register called with nil Validator") + } + name := v.Provider() + if name == "" { + panic("credentials: Validator.Provider() returned empty string") + } + registryMu.Lock() + defer registryMu.Unlock() + registry[name] = v +} + +// LookupValidator returns the Validator for provider, or (nil, false) +// if no Validator is registered. Used by the PUT/GET handlers to +// reject unknown providers with a 400 + clear error. +func LookupValidator(provider string) (Validator, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + v, ok := registry[provider] + return v, ok +} + +// RegisteredProviders returns the names of all currently-registered +// providers. Used by the "what providers does this gateway support" +// summary endpoint and by tests. Order is unspecified. +func RegisteredProviders() []string { + registryMu.RLock() + defer registryMu.RUnlock() + out := make([]string, 0, len(registry)) + for name := range registry { + out = append(out, name) + } + return out +} + +// resetRegistry clears the registry. Used internally by the package's +// own tests; the exported ResetRegistryForTest wrapper makes it +// callable from tests in OTHER packages (which can't reach +// package-internal symbols). +// +// Not safe to call while requests are in flight; intended for test +// setup/teardown ONLY. +func resetRegistry() { + registryMu.Lock() + defer registryMu.Unlock() + registry = map[string]Validator{} +} + +// ResetRegistryForTest clears the global Validator registry. Tests in +// other packages (e.g. the HTTP handler tests) that register +// Validators should defer this so they don't leak state into other +// tests in the same binary. +// +// Exposed as a regular exported function (not _test.go-gated) because +// test files in other packages cannot reach _test.go-only exports of +// THIS package. Safe to call at runtime but pointless outside tests. +func ResetRegistryForTest() { + resetRegistry() +} diff --git a/core/pkg/push/credentials/registry_test.go b/core/pkg/push/credentials/registry_test.go new file mode 100644 index 0000000..e47d5b6 --- /dev/null +++ b/core/pkg/push/credentials/registry_test.go @@ -0,0 +1,116 @@ +package credentials + +import ( + "strings" + "testing" +) + +// fakeValidator is a no-op Validator for registry tests. +type fakeValidator struct{ name string } + +func (v fakeValidator) Provider() string { return v.name } +func (v fakeValidator) Validate(_ []byte) error { return nil } +func (v fakeValidator) Redact(b []byte) (interface{}, error) { return string(b), nil } + +func TestRegistry_RegisterLookup(t *testing.T) { + defer resetRegistry() + resetRegistry() + + Register(fakeValidator{name: "apns"}) + Register(fakeValidator{name: "ntfy"}) + + if _, ok := LookupValidator("apns"); !ok { + t.Error("apns not found after Register") + } + if _, ok := LookupValidator("ntfy"); !ok { + t.Error("ntfy not found after Register") + } + if _, ok := LookupValidator("nonexistent"); ok { + t.Error("LookupValidator returned true for unregistered provider") + } +} + +func TestRegistry_ReregisterReplaces(t *testing.T) { + defer resetRegistry() + resetRegistry() + + Register(fakeValidator{name: "apns"}) + v, _ := LookupValidator("apns") + if v.(fakeValidator).name != "apns" { + t.Fatal("setup: wrong validator returned") + } + + type replacement struct{ fakeValidator } + r := replacement{fakeValidator{name: "apns"}} + Register(r) + got, _ := LookupValidator("apns") + if _, ok := got.(replacement); !ok { + t.Errorf("Re-register did not replace; got %T", got) + } +} + +func TestRegistry_RegisteredProviders(t *testing.T) { + defer resetRegistry() + resetRegistry() + + Register(fakeValidator{name: "apns"}) + Register(fakeValidator{name: "ntfy"}) + Register(fakeValidator{name: "fcm"}) + + names := RegisteredProviders() + if len(names) != 3 { + t.Errorf("expected 3 registered; got %d (%v)", len(names), names) + } + for _, want := range []string{"apns", "ntfy", "fcm"} { + found := false + for _, n := range names { + if n == want { + found = true + break + } + } + if !found { + t.Errorf("expected %q in RegisteredProviders, got %v", want, names) + } + } +} + +func TestRegistry_PanicsOnNilOrEmpty(t *testing.T) { + defer resetRegistry() + resetRegistry() + + defer func() { + r := recover() + if r == nil { + t.Error("expected panic on nil Validator; got none") + } + if !strings.Contains(toString(r), "nil") { + t.Errorf("panic message should mention nil; got %v", r) + } + }() + Register(nil) +} + +func TestRegistry_PanicsOnEmptyName(t *testing.T) { + defer resetRegistry() + resetRegistry() + + defer func() { + r := recover() + if r == nil { + t.Error("expected panic on empty Provider() name; got none") + } + }() + Register(fakeValidator{name: ""}) +} + +func toString(v interface{}) string { + switch s := v.(type) { + case string: + return s + case error: + return s.Error() + default: + return "" + } +} diff --git a/core/pkg/push/credentials/store.go b/core/pkg/push/credentials/store.go new file mode 100644 index 0000000..fa2a581 --- /dev/null +++ b/core/pkg/push/credentials/store.go @@ -0,0 +1,161 @@ +package credentials + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/DeBrosOfficial/network/pkg/rqlite" + "github.com/DeBrosOfficial/network/pkg/secrets" + "go.uber.org/zap" +) + +// purposeNamespacePushCredentials is the HKDF "purpose" string for the +// per-provider credentials encryption key. Distinct from +// "namespace-push-config" (used by the legacy 026 columns) so a key +// compromise in one domain doesn't extend to the other. +const purposeNamespacePushCredentials = "namespace-push-credentials" + +// rqliteStore is the production Store — persists credentials in the +// `namespace_push_credentials` table (migration 028) with AES-256-GCM +// encryption of the JSON blob. +type rqliteStore struct { + db rqlite.Client + encKey []byte + logger *zap.Logger +} + +// NewRqliteStore wires the store to RQLite with a cluster-secret- +// derived encryption key. Returns an error if clusterSecret is empty — +// we refuse to operate without encryption, otherwise an operator-typo +// could ship plaintext p8 keys to disk. +func NewRqliteStore(db rqlite.Client, clusterSecret string, logger *zap.Logger) (Store, error) { + if clusterSecret == "" { + return nil, fmt.Errorf("credentials store: cluster secret required for credential encryption") + } + key, err := secrets.DeriveKey(clusterSecret, purposeNamespacePushCredentials) + if err != nil { + return nil, fmt.Errorf("credentials store: derive key: %w", err) + } + if logger == nil { + logger = zap.NewNop() + } + return &rqliteStore{db: db, encKey: key, logger: logger}, nil +} + +// Get returns the credential, decrypting the JSON blob. Returns +// ErrNotFound if no row exists for (namespace, provider). +func (s *rqliteStore) Get(ctx context.Context, namespace, provider string) (*Credential, error) { + if namespace == "" { + return nil, ErrInvalidNamespace + } + if provider == "" { + return nil, ErrInvalidProvider + } + const q = `SELECT namespace, provider, credentials_json, updated_at, updated_by + FROM namespace_push_credentials + WHERE namespace = ? AND provider = ? LIMIT 1` + var rows []struct { + Namespace string `db:"namespace"` + Provider string `db:"provider"` + CredentialsJSON string `db:"credentials_json"` + UpdatedAt int64 `db:"updated_at"` + UpdatedBy string `db:"updated_by"` + } + if err := s.db.Query(ctx, &rows, q, namespace, provider); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("credentials Get: %w", err) + } + if len(rows) == 0 { + return nil, ErrNotFound + } + r := rows[0] + plain, err := secrets.Decrypt(r.CredentialsJSON, s.encKey) + if err != nil { + return nil, fmt.Errorf("credentials Get: decrypt: %w", err) + } + return &Credential{ + Namespace: r.Namespace, + Provider: r.Provider, + JSON: []byte(plain), + UpdatedAt: r.UpdatedAt, + UpdatedBy: r.UpdatedBy, + }, nil +} + +// Upsert writes or replaces the credential row. The JSON blob is +// AES-256-GCM-encrypted before storage. The caller is responsible for +// validating the JSON against the provider's schema BEFORE calling +// Upsert — this method does not invoke the Validator registry. +func (s *rqliteStore) Upsert(ctx context.Context, cred Credential) error { + if cred.Namespace == "" { + return ErrInvalidNamespace + } + if cred.Provider == "" { + return ErrInvalidProvider + } + if len(cred.JSON) == 0 { + return fmt.Errorf("credentials Upsert: empty JSON payload") + } + enc, err := secrets.Encrypt(string(cred.JSON), s.encKey) + if err != nil { + return fmt.Errorf("credentials Upsert: encrypt: %w", err) + } + updatedAt := cred.UpdatedAt + if updatedAt == 0 { + updatedAt = time.Now().Unix() + } + const q = `INSERT INTO namespace_push_credentials + (namespace, provider, credentials_json, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(namespace, provider) DO UPDATE SET + credentials_json = excluded.credentials_json, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by` + if _, err := s.db.Exec(ctx, q, + cred.Namespace, cred.Provider, enc, updatedAt, cred.UpdatedBy, + ); err != nil { + return fmt.Errorf("credentials Upsert: %w", err) + } + return nil +} + +// Delete clears the (namespace, provider) row. Idempotent. +func (s *rqliteStore) Delete(ctx context.Context, namespace, provider string) error { + if namespace == "" { + return ErrInvalidNamespace + } + if provider == "" { + return ErrInvalidProvider + } + const q = `DELETE FROM namespace_push_credentials WHERE namespace = ? AND provider = ?` + if _, err := s.db.Exec(ctx, q, namespace, provider); err != nil { + return fmt.Errorf("credentials Delete: %w", err) + } + return nil +} + +// ListProviders returns the provider names that have a row for the +// namespace. Used by the credentials-summary endpoint to render the +// "what's configured" view without leaking secret material. +func (s *rqliteStore) ListProviders(ctx context.Context, namespace string) ([]string, error) { + if namespace == "" { + return nil, ErrInvalidNamespace + } + const q = `SELECT provider FROM namespace_push_credentials WHERE namespace = ?` + var rows []struct { + Provider string `db:"provider"` + } + if err := s.db.Query(ctx, &rows, q, namespace); err != nil { + return nil, fmt.Errorf("credentials ListProviders: %w", err) + } + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.Provider + } + return out, nil +} diff --git a/core/pkg/push/credentials/types.go b/core/pkg/push/credentials/types.go new file mode 100644 index 0000000..31dc4f2 --- /dev/null +++ b/core/pkg/push/credentials/types.go @@ -0,0 +1,117 @@ +// Package credentials provides per-namespace, per-provider push-credential +// storage with at-rest encryption. +// +// This package is intentionally provider-agnostic: it knows how to put +// and get an opaque JSON blob keyed by (namespace, provider), and it +// delegates schema validation + redaction to the provider package via a +// Validator registry. Adding a new push provider — APNs, FCM, SMS, +// whatever — requires only: +// +// 1. A provider package that implements credentials.Validator. +// 2. A call to credentials.Register(, validator) from +// the gateway dependency wiring. +// +// No changes here; no schema migration; no new HTTP endpoint. +// +// Feature #72. Mirrors the per-namespace LRU+TTL caching pattern from +// pkg/ratelimit (#69) so cross-gateway config staleness is bounded +// without a pub/sub broadcast layer. +package credentials + +import ( + "context" + "errors" + "time" +) + +// Credential is one row of namespace_push_credentials. +// +// JSON is plaintext in this struct — encryption happens at the storage +// boundary. Callers who load Credentials from the store must treat JSON +// as sensitive material (never log it, never echo it back unredacted). +type Credential struct { + Namespace string + Provider string + JSON []byte // provider-specific schema; owned by the provider package + UpdatedAt int64 // unix seconds + UpdatedBy string // free-form audit (wallet address, operator ID, etc.) +} + +// Store reads and writes per-(namespace, provider) credentials. Production +// implementation is rqlite-backed (see store.go); tests typically swap +// in an in-memory map. +type Store interface { + // Get returns the credential, or ErrNotFound if no row exists for + // (namespace, provider). + Get(ctx context.Context, namespace, provider string) (*Credential, error) + + // Upsert inserts or replaces the credential. cred.UpdatedAt and + // cred.UpdatedBy must be populated by the caller. + Upsert(ctx context.Context, cred Credential) error + + // Delete removes the credential. Idempotent — no error if the row + // didn't exist. + Delete(ctx context.Context, namespace, provider string) error + + // ListProviders returns the provider names that have a row for the + // given namespace. Used by the "what's configured" summary endpoint. + // Order is unspecified. + ListProviders(ctx context.Context, namespace string) ([]string, error) +} + +// Validator is implemented by each provider package to validate and +// redact its own credential JSON schema. The credentials package itself +// never inspects the JSON. +// +// Validate is called by the PUT handler before storage; it should return +// a descriptive error for any malformed or out-of-spec value so the +// tenant gets actionable feedback at PUT time (not at first-push time). +// +// Redact is called by the GET handler after decryption; it MUST NOT +// echo secret material back to the caller. Standard pattern: replace +// each secret string with a boolean "has_" flag, leave non-secret +// fields as-is, and return any JSON-marshalable struct. +type Validator interface { + // Provider returns the provider name (e.g. "apns", "ntfy", "fcm"). Must + // match the URL path segment used at registration. + Provider() string + + // Validate parses rawJSON and returns nil if the schema is acceptable + // for this provider. Errors should be human-readable; they're surfaced + // directly to the tenant in the 400 response. + Validate(rawJSON []byte) error + + // Redact returns a JSON-serializable view of rawJSON with all secret + // fields replaced by `has_` booleans (or otherwise made safe + // for return over HTTP). + Redact(rawJSON []byte) (interface{}, error) +} + +// Sentinel errors. +var ( + // ErrNotFound is returned by Store.Get when no credential exists for + // (namespace, provider). Callers fall back to the legacy 026 config + // (during the ntfy/expo migration window) or treat as "not configured". + ErrNotFound = errors.New("credentials: not found") + + // ErrUnknownProvider is returned by handlers when the URL provider + // segment doesn't have a registered Validator. New providers must + // register their Validator at gateway startup (see registry.go). + ErrUnknownProvider = errors.New("credentials: unknown provider") + + // ErrInvalidNamespace / ErrInvalidProvider catch programmer / input + // errors at the storage boundary. + ErrInvalidNamespace = errors.New("credentials: namespace required") + ErrInvalidProvider = errors.New("credentials: provider required") +) + +// cacheEntryTTL bounds how long a stale Manager cache entry can serve +// before the next lookup re-reads the store. Mirrors the ratelimit +// Manager's TTL (30s) — short enough that operator config changes +// propagate across multi-gateway deployments quickly, long enough that +// the store isn't hit on every push. +const cacheEntryTTL = 30 * time.Second + +// defaultCacheCap caps the Manager's LRU. Each entry is a small (~1 KB) +// decoded credential; 1024 is generous and bounds memory under abuse. +const defaultCacheCap = 1024 diff --git a/core/pkg/push/manager.go b/core/pkg/push/manager.go index 7dd5434..f878bda 100644 --- a/core/pkg/push/manager.go +++ b/core/pkg/push/manager.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "sync" + "time" "go.uber.org/zap" ) @@ -38,13 +39,18 @@ import ( // The factory is called once per fresh dispatcher build (cache miss). // Empty slice is allowed and means "this config produces no providers"; // Manager treats that as ErrPushNotConfigured. -type ProviderFactory func(cfg Config) []PushProvider +// +// The ctx is the request context that triggered the (cold-path) +// dispatcher build. Factories that need to look up per-namespace +// credentials from the credentials manager (e.g. APNs) should use it +// so cancellation propagates correctly. ctx is never nil. +type ProviderFactory func(ctx context.Context, cfg Config) []PushProvider // ErrPushNotConfigured is returned by Send when the namespace has no // per-namespace config AND the gateway has no fallback defaults — i.e. // nothing to send through. Distinguish from ErrNoDevices (different // failure mode). -var ErrPushNotConfigured = errors.New("push not configured for namespace; set ntfy_base_url or expo_access_token via PUT /v1/push/config") +var ErrPushNotConfigured = errors.New("push not configured for namespace; set credentials via PUT /v1/namespace/push-credentials/{provider} or legacy /v1/push/config") // Defaults are the gateway-YAML fallback when a namespace hasn't set its // own config. Any field set here applies to every namespace that doesn't @@ -63,12 +69,27 @@ func (d Defaults) IsEmpty() bool { // Manager is the top-level push entry point. Build with NewManager and // hand out via the gateway's dependencies. Safe for concurrent use. +// +// Cross-gateway invalidation: the per-namespace dispatcher is built +// from BOTH the per-namespace push config (legacy 026) AND any +// per-provider credentials (#72). If a tenant rotates an APNs p8 key +// on gateway A, gateway B's CACHED dispatcher still holds an APNs +// provider constructed from the OLD key — until either: +// +// 1. The dispatcher entry is evicted by LRU pressure (only when +// activeCacheCap namespaces are also active), or +// 2. The entry's TTL elapses (cacheEntryTTL, default 30s). +// +// The TTL is the defense-in-depth bound — same model as pkg/ratelimit. +// Without it, low-traffic namespaces would never see rotated creds on +// gateway B without an explicit broadcast layer. type Manager struct { store ConfigStore devices PushDeviceStore defaults Defaults factory ProviderFactory logger *zap.Logger + ttl time.Duration // configurable for tests // cache LRU of namespace → built dispatcher. mu sync.Mutex @@ -81,6 +102,7 @@ type Manager struct { type cacheEntry struct { namespace string dispatcher *PushDispatcher + builtAt time.Time } // defaultCacheCap caps how many namespaces' dispatchers we hold in memory. @@ -88,6 +110,12 @@ type cacheEntry struct { // memory under abuse. const defaultCacheCap = 256 +// cacheEntryTTL bounds how long a stale dispatcher can serve before the +// next dispatcherFor call rebuilds it from store + credentials. 30s +// matches pkg/ratelimit and pkg/push/credentials so config + creds +// changes propagate across the cluster within the same bounded window. +const cacheEntryTTL = 30 * time.Second + // NewManager constructs a Manager with the given device store, config // store, fallback Defaults, and ProviderFactory. // @@ -105,12 +133,26 @@ func NewManager(devices PushDeviceStore, store ConfigStore, defaults Defaults, f defaults: defaults, factory: factory, logger: logger, + ttl: cacheEntryTTL, cache: make(map[string]*list.Element, defaultCacheCap), lru: list.New(), cacheCap: defaultCacheCap, } } +// SetCacheTTL overrides the default dispatcher cache TTL. Intended +// for tests (where 30s is too long) and for operators who want a +// tighter cross-gateway propagation window. Non-positive values are +// ignored. +func (m *Manager) SetCacheTTL(d time.Duration) { + if d <= 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.ttl = d +} + // SendToUser dispatches a push to every device registered for the user // in the given namespace. Looks up per-namespace config (or falls back // to defaults), builds the appropriate dispatcher, and sends. @@ -165,15 +207,22 @@ func (m *Manager) Invalidate(namespace string) { } // dispatcherFor returns a (cached or freshly built) dispatcher with the -// providers configured for the given namespace. +// providers configured for the given namespace. Entries older than +// `ttl` are evicted on access and rebuilt — this bounds the staleness +// of credential changes that happened on another gateway. func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDispatcher, error) { - // Fast path — already cached. + // Fast path — already cached AND not expired. m.mu.Lock() if elem, ok := m.cache[namespace]; ok { - m.lru.MoveToFront(elem) entry := elem.Value.(*cacheEntry) - m.mu.Unlock() - return entry.dispatcher, nil + if time.Since(entry.builtAt) < m.ttl { + m.lru.MoveToFront(elem) + m.mu.Unlock() + return entry.dispatcher, nil + } + // Expired — drop the stale entry and fall through to rebuild. + m.lru.Remove(elem) + delete(m.cache, namespace) } m.mu.Unlock() @@ -186,10 +235,16 @@ func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDis // Insert into cache (eviction if at capacity). m.mu.Lock() defer m.mu.Unlock() - // Recheck under lock — another goroutine may have built one. + // Recheck under lock — another goroutine may have built one. Use it + // only if it's still fresh; otherwise our newly-built one replaces. if elem, ok := m.cache[namespace]; ok { - m.lru.MoveToFront(elem) - return elem.Value.(*cacheEntry).dispatcher, nil + entry := elem.Value.(*cacheEntry) + if time.Since(entry.builtAt) < m.ttl { + m.lru.MoveToFront(elem) + return entry.dispatcher, nil + } + m.lru.Remove(elem) + delete(m.cache, namespace) } if m.lru.Len() >= m.cacheCap { oldest := m.lru.Back() @@ -199,7 +254,7 @@ func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDis delete(m.cache, old.namespace) } } - entry := &cacheEntry{namespace: namespace, dispatcher: d} + entry := &cacheEntry{namespace: namespace, dispatcher: d, builtAt: time.Now()} m.cache[namespace] = m.lru.PushFront(entry) return d, nil } @@ -241,18 +296,19 @@ func (m *Manager) buildDispatcher(ctx context.Context, namespace string) (*PushD } } - // Refuse to build a dispatcher with no providers — caller gets a - // clear error instead of a silent no-op. - if eff.NtfyBaseURL == "" && eff.ExpoAccessToken == "" { - return nil, ErrPushNotConfigured - } if m.factory == nil { // Defensive: a Manager built without a factory can't produce // providers. Programmer error; surface explicitly. return nil, fmt.Errorf("manager: no provider factory configured") } - providers := m.factory(eff) + // Authoritative provider-presence check is at the factory output — + // not at the resolved flat-field config — because providers can + // also be sourced from the per-namespace credentials store + // (feature #72: APNs is fully credentialed and has no flat field + // here). The factory returns an empty slice when nothing is + // configured, which we translate to ErrPushNotConfigured. + providers := m.factory(ctx, eff) if len(providers) == 0 { return nil, ErrPushNotConfigured } diff --git a/core/pkg/push/manager_test.go b/core/pkg/push/manager_test.go index 08aede8..60ae2c8 100644 --- a/core/pkg/push/manager_test.go +++ b/core/pkg/push/manager_test.go @@ -77,7 +77,7 @@ func TestManager_namespace_with_no_config_uses_defaults(t *testing.T) { defaults := Defaults{NtfyBaseURL: "http://default-ntfy"} var providerCalls atomic.Int32 - factory := func(c Config) []PushProvider { + factory := func(_ context.Context, c Config) []PushProvider { providerCalls.Add(1) // Verify the manager passed defaults through to the factory. if c.NtfyBaseURL != "http://default-ntfy" { @@ -108,7 +108,7 @@ func TestManager_namespace_config_overrides_defaults(t *testing.T) { defaults := Defaults{NtfyBaseURL: "http://default-ntfy"} var seenURL string - factory := func(c Config) []PushProvider { + factory := func(_ context.Context, c Config) []PushProvider { seenURL = c.NtfyBaseURL return []PushProvider{&managerFakeProvider{name: "ntfy"}} } @@ -124,7 +124,7 @@ func TestManager_namespace_config_overrides_defaults(t *testing.T) { func TestManager_no_config_no_defaults_returns_ErrPushNotConfigured(t *testing.T) { store := newFakeConfigStore() - factory := func(_ Config) []PushProvider { return nil } + factory := func(_ context.Context, _ Config) []PushProvider { return nil } m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop()) _, err := m.dispatcherFor(context.Background(), "ns-A") @@ -138,7 +138,7 @@ func TestManager_caches_dispatchers_per_namespace(t *testing.T) { store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "u"}) var factoryCalls atomic.Int32 - factory := func(_ Config) []PushProvider { + factory := func(_ context.Context, _ Config) []PushProvider { factoryCalls.Add(1) return []PushProvider{&managerFakeProvider{name: "ntfy"}} } @@ -160,7 +160,7 @@ func TestManager_invalidate_forces_rebuild(t *testing.T) { store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "v1"}) var seenURLs []string - factory := func(c Config) []PushProvider { + factory := func(_ context.Context, c Config) []PushProvider { seenURLs = append(seenURLs, c.NtfyBaseURL) return []PushProvider{&managerFakeProvider{name: "ntfy"}} } @@ -190,7 +190,7 @@ func TestManager_per_namespace_isolation(t *testing.T) { urlByNS := make(map[string]string) var mu sync.Mutex - factory := func(c Config) []PushProvider { + factory := func(_ context.Context, c Config) []PushProvider { mu.Lock() urlByNS[c.Namespace] = c.NtfyBaseURL mu.Unlock() @@ -238,7 +238,7 @@ func TestManager_concurrent_dispatcherFor_no_race(t *testing.T) { // Run with -race. store := newFakeConfigStore() store.Upsert(context.Background(), Config{Namespace: "ns", NtfyBaseURL: "u"}) - factory := func(_ Config) []PushProvider { return []PushProvider{&managerFakeProvider{name: "ntfy"}} } + factory := func(_ context.Context, _ Config) []PushProvider { return []PushProvider{&managerFakeProvider{name: "ntfy"}} } m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop()) diff --git a/core/pkg/push/providers/apns/apns.go b/core/pkg/push/providers/apns/apns.go new file mode 100644 index 0000000..9975220 --- /dev/null +++ b/core/pkg/push/providers/apns/apns.go @@ -0,0 +1,180 @@ +package apns + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/DeBrosOfficial/network/pkg/push" + "github.com/sideshow/apns2" + "github.com/sideshow/apns2/token" + "go.uber.org/zap" +) + +// defaultSendTimeout bounds each apns.Push call. APNs is usually <100ms +// but mobile networks + Apple-side slowness occasionally push to seconds. +// 10 seconds is a comfortable upper bound; faster than the legacy ntfy +// provider's 5s because APNs is HTTP/2 + connection-reused. +const defaultSendTimeout = 10 * time.Second + +// Provider is the APNs push.PushProvider implementation, scoped to one +// (Team ID, Key ID, p8 key, Bundle ID, Environment) tuple. Construct +// one per namespace via the gateway dependency factory. +type Provider struct { + bundleID string + client pushClient + logger *zap.Logger +} + +// pushClient is the subset of *apns2.Client this provider uses, +// extracted so tests can substitute a fake without spinning up an HTTPS +// server with a self-signed APNs cert. +// +// We use PushWithContext (not Push) so context cancellation actually +// reaches the underlying HTTP/2 stream — otherwise an abandoned ctx +// leaves the request running until apns2's internal HTTPClient.Timeout +// fires, leaking a goroutine and a connection per cancelled send. +// +// The first arg is `apns2.Context` (which embeds context.Context) to +// match the upstream signature exactly — any standard context.Context +// satisfies apns2.Context's single-method interface. +type pushClient interface { + PushWithContext(ctx apns2.Context, notification *apns2.Notification) (*apns2.Response, error) +} + +// New constructs a Provider from a parsed Config. Returns an error if +// the p8 key fails to parse — this surfaces config errors at gateway +// startup / first-send rather than at every Push call. +func New(c Config, logger *zap.Logger) (*Provider, error) { + if logger == nil { + logger = zap.NewNop() + } + if err := validateConfig(c); err != nil { + return nil, err + } + authKey, err := token.AuthKeyFromBytes([]byte(c.P8Key)) + if err != nil { + return nil, fmt.Errorf("apns: parse p8 key: %w", err) + } + tok := &token.Token{ + AuthKey: authKey, + KeyID: c.KeyID, + TeamID: c.TeamID, + } + client := apns2.NewTokenClient(tok) + switch c.Environment { + case EnvProduction: + client = client.Production() + case EnvSandbox: + client = client.Development() + default: + // validateConfig already rejected anything else. + return nil, fmt.Errorf("apns: unsupported environment %q", c.Environment) + } + // Override the underlying HTTP/2 client's per-request timeout. apns2's + // default of zero means "no timeout" — bad for a server-side context. + client.HTTPClient.Timeout = defaultSendTimeout + return &Provider{ + bundleID: c.BundleID, + client: client, + logger: logger.Named("apns"), + }, nil +} + +// Name implements push.PushProvider. +func (p *Provider) Name() string { return "apns" } + +// ErrDeviceUnregistered is returned by Send when APNs responds with +// "Unregistered" (HTTP 410) — the token is no longer valid because the +// user uninstalled the app, disabled notifications, or upgraded device. +// Callers SHOULD delete the device row when they see this so the same +// dead token doesn't get retried forever. +var ErrDeviceUnregistered = errors.New("apns: device token unregistered (410); remove from device store") + +// Send delivers one push to the APNs server. Constructs the APNs +// JSON payload from PushMessage, dispatches via the sideshow/apns2 +// client, and maps response codes to errors. +func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error { + if msg.DeviceToken == "" { + return push.ErrEmptyToken + } + payload, err := buildAPSPayload(msg) + if err != nil { + return fmt.Errorf("apns: build payload: %w", err) + } + n := &apns2.Notification{ + DeviceToken: msg.DeviceToken, + Topic: p.bundleID, + Payload: payload, + } + // Priority mapping: APNs uses 10 (immediate) / 5 (power-saving). + if msg.Priority == push.PriorityHigh { + n.Priority = apns2.PriorityHigh + } else { + n.Priority = apns2.PriorityLow + } + + // PushWithContext propagates cancellation through to the HTTP/2 + // stream — abandoning ctx terminates the in-flight request, no + // goroutine leak. + resp, sendErr := p.client.PushWithContext(ctx, n) + if sendErr != nil { + return fmt.Errorf("apns: push: %w", sendErr) + } + if resp == nil { + return fmt.Errorf("apns: nil response") + } + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusGone: + // 410 Unregistered — surfaced as a sentinel so the dispatcher + // (or caller) can remove the device row. + return fmt.Errorf("%w: apns_id=%s reason=%s", ErrDeviceUnregistered, resp.ApnsID, resp.Reason) + default: + return fmt.Errorf("apns: http %d: reason=%s apns_id=%s", + resp.StatusCode, resp.Reason, resp.ApnsID) + } +} + +// buildAPSPayload assembles the APNs JSON payload from a generic +// PushMessage. The `aps` dictionary is the Apple-required wrapper; +// custom fields (`data`) go alongside at the top level. +// +// Reference: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification +func buildAPSPayload(msg push.PushMessage) ([]byte, error) { + alert := map[string]string{} + if msg.Title != "" { + alert["title"] = msg.Title + } + if msg.Body != "" { + alert["body"] = msg.Body + } + aps := map[string]interface{}{} + if len(alert) > 0 { + aps["alert"] = alert + } + if msg.Badge > 0 { + aps["badge"] = msg.Badge + } + if msg.Sound != "" { + aps["sound"] = msg.Sound + } + if msg.Channel != "" { + // Apple's "thread-id" groups notifications into a conversation in + // the lock-screen view. Channel is the most natural mapping. + aps["thread-id"] = msg.Channel + } + root := map[string]interface{}{"aps": aps} + for k, v := range msg.Data { + // Don't allow tenant data to clobber `aps`. + if k == "aps" { + continue + } + root[k] = v + } + return json.Marshal(root) +} diff --git a/core/pkg/push/providers/apns/apns_test.go b/core/pkg/push/providers/apns/apns_test.go new file mode 100644 index 0000000..dac650b --- /dev/null +++ b/core/pkg/push/providers/apns/apns_test.go @@ -0,0 +1,372 @@ +package apns + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/DeBrosOfficial/network/pkg/push" + "github.com/sideshow/apns2" +) + +// fakePushClient implements pushClient for unit tests so we don't have +// to spin up a TLS endpoint mimicking api.push.apple.com. +// +// `block` (when non-nil) makes PushWithContext block until either the +// channel closes OR ctx is cancelled — used by ctx-cancellation tests. +type fakePushClient struct { + resp *apns2.Response + err error + lastSent *apns2.Notification + block chan struct{} // optional — blocks Push until ctx done or channel closed +} + +func (f *fakePushClient) PushWithContext(ctx apns2.Context, n *apns2.Notification) (*apns2.Response, error) { + f.lastSent = n + if f.block != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-f.block: + } + } + return f.resp, f.err +} + +// newTestProvider constructs a Provider with a stub pushClient, +// bypassing real APNs. +func newTestProvider(t *testing.T, bundle string, fake *fakePushClient) *Provider { + t.Helper() + return &Provider{ + bundleID: bundle, + client: fake, + } +} + +// validP8 is a real-looking PEM-encoded EC P-256 private key. Not the +// real one — generated for tests only. Used to validate the +// happy-path constructor; New() will still fail because authKey parsing +// will reject this synthetic key, so we don't use it for Send() tests. +const validP8 = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg2pV1mEzh4n1mY3y4 +i7Ww8gJZ7lxFm6dlGn3PMOzCq2egCgYIKoZIzj0DAQehRANCAAS8Pn8VKWUe9wm8 +e1JFvSTSj1RxLm2sj8cKpFnSdF5g3kfQ9ueJmFVnZbR3VRJOzn0FNyEJYUkXOdYx +PRIVATE_KEY_PLACEHOLDER== +-----END PRIVATE KEY-----` + +// ---- Validator tests ------------------------------------------------ + +func TestValidator_AcceptsWellFormedConfig(t *testing.T) { + v := NewValidator() + raw := []byte(`{ + "team_id": "ABCDEFGHIJ", + "key_id": "1234567890", + "bundle_id": "com.example.app", + "p8_key": "-----BEGIN PRIVATE KEY-----\nMIGTAg...\n-----END PRIVATE KEY-----", + "environment": "production" + }`) + if err := v.Validate(raw); err != nil { + t.Errorf("expected valid config to pass; got %v", err) + } +} + +func TestValidator_RejectsMissingFields(t *testing.T) { + v := NewValidator() + tests := []struct { + name string + body string + want string + }{ + {"no team_id", `{"key_id":"1234567890","bundle_id":"com.x","p8_key":"-----BEGIN PRIVATE KEY-----","environment":"sandbox"}`, "team_id required"}, + {"short team_id", `{"team_id":"ABC","key_id":"1234567890","bundle_id":"com.x.y","p8_key":"-----BEGIN PRIVATE KEY-----","environment":"sandbox"}`, "team_id must be 10"}, + {"no key_id", `{"team_id":"ABCDEFGHIJ","bundle_id":"com.x.y","p8_key":"-----BEGIN PRIVATE KEY-----","environment":"sandbox"}`, "key_id required"}, + {"no bundle_id", `{"team_id":"ABCDEFGHIJ","key_id":"1234567890","p8_key":"-----BEGIN PRIVATE KEY-----","environment":"sandbox"}`, "bundle_id required"}, + {"bundle_id no dot", `{"team_id":"ABCDEFGHIJ","key_id":"1234567890","bundle_id":"comx","p8_key":"-----BEGIN PRIVATE KEY-----","environment":"sandbox"}`, "reverse-DNS"}, + {"no p8_key", `{"team_id":"ABCDEFGHIJ","key_id":"1234567890","bundle_id":"com.x.y","environment":"sandbox"}`, "p8_key required"}, + {"p8_key not PEM", `{"team_id":"ABCDEFGHIJ","key_id":"1234567890","bundle_id":"com.x.y","p8_key":"not-pem","environment":"sandbox"}`, "PEM-encoded"}, + {"bad env", `{"team_id":"ABCDEFGHIJ","key_id":"1234567890","bundle_id":"com.x.y","p8_key":"-----BEGIN PRIVATE KEY-----","environment":"staging"}`, "sandbox"}, + {"no env", `{"team_id":"ABCDEFGHIJ","key_id":"1234567890","bundle_id":"com.x.y","p8_key":"-----BEGIN PRIVATE KEY-----"}`, "environment required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := v.Validate([]byte(tt.body)) + if err == nil { + t.Fatalf("expected error containing %q; got nil", tt.want) + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error = %v; want substring %q", err, tt.want) + } + }) + } +} + +func TestValidator_RejectsMalformedJSON(t *testing.T) { + v := NewValidator() + if err := v.Validate([]byte(`{not json`)); err == nil { + t.Error("expected JSON parse error") + } +} + +func TestValidator_RedactNeverEchoesP8Key(t *testing.T) { + v := NewValidator() + raw := []byte(`{ + "team_id": "ABCDEFGHIJ", + "key_id": "1234567890", + "bundle_id": "com.example.app", + "p8_key": "-----BEGIN PRIVATE KEY-----\nSUPERSECRETKEY\n-----END PRIVATE KEY-----", + "environment": "production" + }`) + out, err := v.Redact(raw) + if err != nil { + t.Fatalf("redact: %v", err) + } + enc, _ := json.Marshal(out) + if strings.Contains(string(enc), "SUPERSECRETKEY") { + t.Errorf("redacted output leaks p8 key material: %s", enc) + } + if strings.Contains(string(enc), "BEGIN PRIVATE KEY") { + t.Errorf("redacted output includes PEM header: %s", enc) + } + // Should still surface non-secret fields for tenant confirmation. + if !strings.Contains(string(enc), "ABCDEFGHIJ") { + t.Errorf("redacted output should include team_id; got %s", enc) + } + if !strings.Contains(string(enc), `"has_p8_key":true`) { + t.Errorf("redacted output should set has_p8_key=true; got %s", enc) + } +} + +// ---- buildAPSPayload tests ------------------------------------------ + +func TestBuildAPSPayload_basicAlert(t *testing.T) { + msg := push.PushMessage{Title: "hi", Body: "from orama"} + raw, err := buildAPSPayload(msg) + if err != nil { + t.Fatalf("build: %v", err) + } + var out struct { + APS struct { + Alert struct { + Title, Body string + } + } `json:"aps"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.APS.Alert.Title != "hi" || out.APS.Alert.Body != "from orama" { + t.Errorf("alert wrong: %+v", out.APS.Alert) + } +} + +func TestBuildAPSPayload_dataAlongsideAPS(t *testing.T) { + msg := push.PushMessage{ + Title: "x", + Body: "y", + Data: map[string]interface{}{"thread": "abc", "deeplink": "anchat://room/42"}, + } + raw, _ := buildAPSPayload(msg) + var out map[string]interface{} + _ = json.Unmarshal(raw, &out) + if _, hasAPS := out["aps"]; !hasAPS { + t.Error("payload missing aps") + } + if out["thread"] != "abc" { + t.Errorf("data.thread missing; got %v", out) + } + if out["deeplink"] != "anchat://room/42" { + t.Errorf("data.deeplink missing; got %v", out) + } +} + +func TestBuildAPSPayload_dataCannotClobberAPS(t *testing.T) { + msg := push.PushMessage{ + Title: "x", + Data: map[string]interface{}{"aps": "evil"}, + } + raw, _ := buildAPSPayload(msg) + var out map[string]interface{} + _ = json.Unmarshal(raw, &out) + apsField, ok := out["aps"] + if !ok { + t.Fatal("aps missing") + } + if _, isMap := apsField.(map[string]interface{}); !isMap { + t.Errorf("aps overwritten by tenant data: got %T (%v)", apsField, apsField) + } +} + +func TestBuildAPSPayload_badgeAndSound(t *testing.T) { + msg := push.PushMessage{ + Title: "x", Badge: 3, Sound: "ding.caf", + } + raw, _ := buildAPSPayload(msg) + if !strings.Contains(string(raw), `"badge":3`) { + t.Errorf("badge not in payload: %s", raw) + } + if !strings.Contains(string(raw), `"sound":"ding.caf"`) { + t.Errorf("sound not in payload: %s", raw) + } +} + +func TestBuildAPSPayload_channelMapsToThreadID(t *testing.T) { + msg := push.PushMessage{Title: "x", Channel: "messages"} + raw, _ := buildAPSPayload(msg) + if !strings.Contains(string(raw), `"thread-id":"messages"`) { + t.Errorf("channel not mapped to thread-id: %s", raw) + } +} + +// ---- Send dispatch tests -------------------------------------------- + +func TestSend_Success(t *testing.T) { + fake := &fakePushClient{ + resp: &apns2.Response{StatusCode: http.StatusOK, ApnsID: "abc-123"}, + } + p := newTestProvider(t, "com.example.app", fake) + err := p.Send(context.Background(), push.PushMessage{ + DeviceToken: "ABCDEF1234", + Title: "hello", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if fake.lastSent == nil { + t.Fatal("Send didn't dispatch to client") + } + if fake.lastSent.Topic != "com.example.app" { + t.Errorf("topic = %q; want com.example.app", fake.lastSent.Topic) + } + if fake.lastSent.DeviceToken != "ABCDEF1234" { + t.Errorf("token mismatch: %q", fake.lastSent.DeviceToken) + } +} + +func TestSend_EmptyTokenRejected(t *testing.T) { + p := newTestProvider(t, "com.example.app", &fakePushClient{}) + err := p.Send(context.Background(), push.PushMessage{Title: "x"}) + if !errors.Is(err, push.ErrEmptyToken) { + t.Errorf("expected ErrEmptyToken; got %v", err) + } +} + +func TestSend_Gone410ReturnsSentinel(t *testing.T) { + fake := &fakePushClient{ + resp: &apns2.Response{StatusCode: http.StatusGone, Reason: "Unregistered", ApnsID: "x"}, + } + p := newTestProvider(t, "com.example.app", fake) + err := p.Send(context.Background(), push.PushMessage{DeviceToken: "t", Title: "x"}) + if !errors.Is(err, ErrDeviceUnregistered) { + t.Errorf("expected ErrDeviceUnregistered for 410; got %v", err) + } + if !strings.Contains(err.Error(), "Unregistered") { + t.Errorf("error should include APNs reason; got %v", err) + } +} + +func TestSend_OtherErrorStatusBubblesUp(t *testing.T) { + fake := &fakePushClient{ + resp: &apns2.Response{StatusCode: http.StatusForbidden, Reason: "BadDeviceToken"}, + } + p := newTestProvider(t, "com.example.app", fake) + err := p.Send(context.Background(), push.PushMessage{DeviceToken: "t", Title: "x"}) + if err == nil { + t.Fatal("expected error on 403") + } + if errors.Is(err, ErrDeviceUnregistered) { + t.Error("403 should not be classified as Unregistered") + } + if !strings.Contains(err.Error(), "BadDeviceToken") { + t.Errorf("error should surface reason; got %v", err) + } +} + +func TestSend_NilResponseHandled(t *testing.T) { + fake := &fakePushClient{} // both nil + p := newTestProvider(t, "com.example.app", fake) + err := p.Send(context.Background(), push.PushMessage{DeviceToken: "t", Title: "x"}) + if err == nil { + t.Fatal("expected error on nil response") + } +} + +func TestSend_ContextCancellationPropagates(t *testing.T) { + // Regression: previously Send launched a goroutine and selected on + // ctx.Done — which made cancel "work" from the caller's point of + // view, but the in-flight request kept running until the apns2 + // client's HTTPClient.Timeout fired (10s). PushWithContext fixes + // this by routing ctx into the HTTP/2 stream. + fake := &fakePushClient{ + resp: &apns2.Response{StatusCode: 200}, + block: make(chan struct{}), // never closed → blocks forever absent ctx cancel + } + p := newTestProvider(t, "com.example.app", fake) + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel almost immediately. + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := p.Send(ctx, push.PushMessage{DeviceToken: "t", Title: "x"}) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected cancellation error; got nil") + } + // Must have returned via the ctx-cancel path, not the (non-existent) + // fallback timeout. Should be well under 1 second. + if elapsed > 1*time.Second { + t.Errorf("Send took too long under cancellation (%v); ctx should kill the request promptly", elapsed) + } +} + +func TestSend_HighPrioritySetsAPNsHigh(t *testing.T) { + fake := &fakePushClient{ + resp: &apns2.Response{StatusCode: http.StatusOK}, + } + p := newTestProvider(t, "com.example.app", fake) + _ = p.Send(context.Background(), push.PushMessage{ + DeviceToken: "t", + Title: "x", + Priority: push.PriorityHigh, + }) + if fake.lastSent.Priority != apns2.PriorityHigh { + t.Errorf("Priority = %d; want %d", fake.lastSent.Priority, apns2.PriorityHigh) + } +} + +// ---- ParseCredentials tests ----------------------------------------- + +func TestParseCredentials_RoundTrip(t *testing.T) { + raw := []byte(`{ + "team_id":"ABCDEFGHIJ", + "key_id":"1234567890", + "bundle_id":"com.example.app", + "p8_key":"-----BEGIN PRIVATE KEY-----\nzzz\n-----END PRIVATE KEY-----", + "environment":"sandbox" + }`) + c, err := ParseCredentials(raw) + if err != nil { + t.Fatalf("ParseCredentials: %v", err) + } + if c.TeamID != "ABCDEFGHIJ" || c.KeyID != "1234567890" { + t.Errorf("wrong: %+v", c) + } + if c.Environment != EnvSandbox { + t.Errorf("env = %s; want sandbox", c.Environment) + } +} + +func TestParseCredentials_RejectsBadConfig(t *testing.T) { + raw := []byte(`{"team_id":"too-short"}`) + if _, err := ParseCredentials(raw); err == nil { + t.Error("expected error on bad config") + } +} diff --git a/core/pkg/push/providers/apns/credentials.go b/core/pkg/push/providers/apns/credentials.go new file mode 100644 index 0000000..e351511 --- /dev/null +++ b/core/pkg/push/providers/apns/credentials.go @@ -0,0 +1,150 @@ +// Package apns implements a push.PushProvider backed by Apple Push +// Notification service via token-based (p8 key) authentication. +// +// Feature #72 — direct APNs delivery. The platform owns no Apple +// Developer credentials; each namespace brings its own p8 key, Team +// ID, Key ID, and Bundle ID via PUT /v1/namespace/push-credentials/apns. +// The credential JSON is stored encrypted at rest by pkg/push/credentials +// and parsed here (ParseCredentials) when the namespace dispatcher is +// built. +// +// Reference: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns +package apns + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DeBrosOfficial/network/pkg/push/credentials" +) + +// Environment selects which APNs endpoint the provider talks to: +// - "sandbox" → api.development.push.apple.com (TestFlight / Xcode builds) +// - "production" → api.push.apple.com (App Store) +// +// Mismatched environment + device token = "BadDeviceToken" (403) at +// send time. The tenant is responsible for matching their app's build +// channel to the registered environment. +type Environment string + +const ( + EnvSandbox Environment = "sandbox" + EnvProduction Environment = "production" +) + +// Config is the per-namespace APNs credential record. JSON tags mirror +// the public schema tenants PUT to /v1/namespace/push-credentials/apns. +// +// p8_key is the FULL PEM-encoded private key, including the +// `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines. +// Do NOT strip the header/footer — the parsing library requires them. +type Config struct { + TeamID string `json:"team_id"` // Apple Developer Team ID, 10 chars + KeyID string `json:"key_id"` // APNs Auth Key ID, 10 chars + BundleID string `json:"bundle_id"` // e.g. "com.example.app" — must match iOS app + P8Key string `json:"p8_key"` // PEM-encoded EC P-256 private key + Environment Environment `json:"environment"` // "sandbox" | "production" +} + +// Validator implements credentials.Validator for the APNs provider. +type Validator struct{} + +// NewValidator returns the singleton Validator used for registration +// with credentials.Register at gateway startup. +func NewValidator() credentials.Validator { return Validator{} } + +// Provider returns "apns". +func (Validator) Provider() string { return "apns" } + +// Validate parses + sanity-checks the credential JSON. +// +// We do NOT verify the p8 key against Apple here (would require a +// network round-trip and Apple charges per APNs call). The parse-and- +// shape check catches the obvious bad-input cases at PUT time so +// tenants don't discover a typo only at first-push. +func (Validator) Validate(raw []byte) error { + var c Config + if err := json.Unmarshal(raw, &c); err != nil { + return fmt.Errorf("apns credentials: invalid JSON: %w", err) + } + return validateConfig(c) +} + +// Redact returns a JSON-safe view that NEVER echoes the p8 key. Other +// fields (Team ID, Key ID, Bundle ID, Environment) are not secret in +// the cryptographic sense — they're identifiers Apple prints in your +// dashboard — so we return them verbatim, which lets the tenant +// confirm what's configured without needing to PUT-and-fetch again. +func (Validator) Redact(raw []byte) (interface{}, error) { + var c Config + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("apns redact: invalid JSON: %w", err) + } + return struct { + TeamID string `json:"team_id"` + KeyID string `json:"key_id"` + BundleID string `json:"bundle_id"` + Environment Environment `json:"environment"` + HasP8Key bool `json:"has_p8_key"` + }{ + TeamID: c.TeamID, + KeyID: c.KeyID, + BundleID: c.BundleID, + Environment: c.Environment, + HasP8Key: c.P8Key != "", + }, nil +} + +// ParseCredentials decodes the raw JSON stored in +// namespace_push_credentials.credentials_json into a typed Config. +// Called by the gateway dependency factory when building a per- +// namespace dispatcher. +func ParseCredentials(raw []byte) (Config, error) { + var c Config + if err := json.Unmarshal(raw, &c); err != nil { + return Config{}, fmt.Errorf("apns ParseCredentials: %w", err) + } + if err := validateConfig(c); err != nil { + return Config{}, err + } + return c, nil +} + +// validateConfig is the shared validator used by both Validate and +// ParseCredentials. Returns nil iff the Config is acceptable. +func validateConfig(c Config) error { + if c.TeamID == "" { + return fmt.Errorf("apns credentials: team_id required") + } + if len(c.TeamID) != 10 { + return fmt.Errorf("apns credentials: team_id must be 10 characters (got %d)", len(c.TeamID)) + } + if c.KeyID == "" { + return fmt.Errorf("apns credentials: key_id required") + } + if len(c.KeyID) != 10 { + return fmt.Errorf("apns credentials: key_id must be 10 characters (got %d)", len(c.KeyID)) + } + if c.BundleID == "" { + return fmt.Errorf("apns credentials: bundle_id required") + } + if !strings.Contains(c.BundleID, ".") { + return fmt.Errorf("apns credentials: bundle_id must be reverse-DNS (e.g. com.example.app), got %q", c.BundleID) + } + if c.P8Key == "" { + return fmt.Errorf("apns credentials: p8_key required") + } + if !strings.Contains(c.P8Key, "BEGIN PRIVATE KEY") { + return fmt.Errorf("apns credentials: p8_key must be PEM-encoded (missing BEGIN PRIVATE KEY header)") + } + switch c.Environment { + case EnvSandbox, EnvProduction: + // ok + case "": + return fmt.Errorf("apns credentials: environment required (\"sandbox\" or \"production\")") + default: + return fmt.Errorf("apns credentials: environment must be \"sandbox\" or \"production\" (got %q)", c.Environment) + } + return nil +} diff --git a/core/pkg/push/providers/ntfy/credentials.go b/core/pkg/push/providers/ntfy/credentials.go new file mode 100644 index 0000000..64e7e74 --- /dev/null +++ b/core/pkg/push/providers/ntfy/credentials.go @@ -0,0 +1,149 @@ +package ntfy + +// credentials.go — ntfy's plug-in for pkg/push/credentials (feature #72). +// +// Lets tenants store their ntfy auth_token (and optionally override the +// base_url for full server sovereignty) via PUT +// /v1/namespace/push-credentials/ntfy. +// +// Topic-format selection is also configured here. The opaque sha256 +// mode is the default (privacy-first); tenants can opt into readable +// modes when they actively want them. +// +// Backward-compat: tenants whose ntfy_auth_token is still in +// namespace_push_config (migration 026) continue to work — the gateway +// factory in dependencies.go reads from BOTH sources during the +// migration window, with the new credentials store taking precedence. + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DeBrosOfficial/network/pkg/push/credentials" +) + +// TopicMode selects how device tokens (ntfy topics) are generated. +// Tenants pick at namespace registration time; their iOS/Android +// clients must agree on the same mode or messages get routed to the +// wrong topic and never delivered. +type TopicMode string + +const ( + // TopicModeOpaque hashes (namespace | userId | topic_secret) to + // sha256 and uses the hex digest as the topic. Leaks NOTHING to a + // public-topic scraper. Recommended default for privacy. + TopicModeOpaque TopicMode = "opaque" + + // TopicModePath uses "ns//" as the topic. + // Readable / debuggable; exposes which users have push enabled to + // anyone enumerating topics. + TopicModePath TopicMode = "path" + + // TopicModeUser uses just "" as the topic. Minimal — leaks + // user IDs but not namespace. + TopicModeUser TopicMode = "user" +) + +// Credentials is the per-namespace ntfy credential record. JSON tags +// mirror the public schema tenants PUT to +// /v1/namespace/push-credentials/ntfy. +// +// Distinct from the existing `Config` (which is the construction-time +// HTTP-client config); the gateway factory parses Credentials, then +// merges them into a Config used to instantiate the Provider. +// +// All fields are optional — an empty record is valid and means "use +// the gateway YAML defaults". The gateway factory layers this on top +// of any legacy 026 row (which takes effect only if the new record is +// absent). +// +// `topic_secret` is required when `topic_mode = "opaque"`. The same +// secret must be known to both the device client (to compute its own +// topic) and the gateway (to compute the topic it sends to). Tenants +// MUST distribute the secret to their clients via a path they trust +// (typically baked into the app's signed config). +type Credentials struct { + BaseURL string `json:"base_url,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + TopicMode TopicMode `json:"topic_mode,omitempty"` + TopicSecret string `json:"topic_secret,omitempty"` +} + +// Validator implements credentials.Validator for the ntfy provider. +type Validator struct{} + +// NewValidator returns the singleton Validator for registration with +// credentials.Register at gateway startup. +func NewValidator() credentials.Validator { return Validator{} } + +// Provider returns "ntfy". +func (Validator) Provider() string { return "ntfy" } + +// Validate parses + checks the credential JSON. Soft on missing fields +// (each is independently optional), strict on schema correctness. +func (Validator) Validate(raw []byte) error { + var c Credentials + if err := json.Unmarshal(raw, &c); err != nil { + return fmt.Errorf("ntfy credentials: invalid JSON: %w", err) + } + return validateCredentials(c) +} + +// Redact returns a JSON-safe view that never echoes the auth token or +// topic secret. Non-secret fields (BaseURL, TopicMode) are returned +// verbatim so tenants can confirm what's configured. +func (Validator) Redact(raw []byte) (interface{}, error) { + var c Credentials + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("ntfy redact: invalid JSON: %w", err) + } + return struct { + BaseURL string `json:"base_url,omitempty"` + TopicMode TopicMode `json:"topic_mode,omitempty"` + HasAuthToken bool `json:"has_auth_token"` + HasTopicSecret bool `json:"has_topic_secret"` + }{ + BaseURL: c.BaseURL, + TopicMode: c.TopicMode, + HasAuthToken: c.AuthToken != "", + HasTopicSecret: c.TopicSecret != "", + }, nil +} + +// ParseCredentials decodes raw JSON from namespace_push_credentials +// into a typed Credentials. Returns an error if validation fails. +func ParseCredentials(raw []byte) (Credentials, error) { + var c Credentials + if err := json.Unmarshal(raw, &c); err != nil { + return Credentials{}, fmt.Errorf("ntfy ParseCredentials: %w", err) + } + if err := validateCredentials(c); err != nil { + return Credentials{}, err + } + return c, nil +} + +// validateCredentials is the shared validator used by both Validate and +// ParseCredentials. +func validateCredentials(c Credentials) error { + if c.BaseURL != "" { + if !strings.HasPrefix(c.BaseURL, "http://") && !strings.HasPrefix(c.BaseURL, "https://") { + return fmt.Errorf("ntfy credentials: base_url must start with http:// or https:// (got %q)", c.BaseURL) + } + } + if c.TopicMode != "" { + switch c.TopicMode { + case TopicModeOpaque, TopicModePath, TopicModeUser: + // ok + default: + return fmt.Errorf("ntfy credentials: topic_mode must be one of \"opaque\", \"path\", \"user\" (got %q)", c.TopicMode) + } + } + if c.TopicMode == TopicModeOpaque && c.TopicSecret == "" { + return fmt.Errorf("ntfy credentials: topic_secret required when topic_mode=\"opaque\"") + } + // AuthToken is always optional — public ntfy servers don't require + // auth. No length check; ntfy accepts arbitrary bearer tokens. + return nil +} diff --git a/core/pkg/push/providers/ntfy/credentials_test.go b/core/pkg/push/providers/ntfy/credentials_test.go new file mode 100644 index 0000000..5dfc1c6 --- /dev/null +++ b/core/pkg/push/providers/ntfy/credentials_test.go @@ -0,0 +1,112 @@ +package ntfy + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestValidator_AcceptsEmpty(t *testing.T) { + if err := NewValidator().Validate([]byte(`{}`)); err != nil { + t.Errorf("empty config should be acceptable (all fields optional); got %v", err) + } +} + +func TestValidator_RejectsBadBaseURL(t *testing.T) { + cases := []string{ + `{"base_url":"ftp://example.com"}`, + `{"base_url":"example.com"}`, + `{"base_url":"just-text"}`, + } + for _, c := range cases { + if err := NewValidator().Validate([]byte(c)); err == nil { + t.Errorf("expected error for %s", c) + } + } +} + +func TestValidator_AcceptsHttpAndHttps(t *testing.T) { + for _, base := range []string{"http://push.local:8080", "https://push.example.com"} { + body, _ := json.Marshal(Credentials{BaseURL: base}) + if err := NewValidator().Validate(body); err != nil { + t.Errorf("base_url=%q rejected: %v", base, err) + } + } +} + +func TestValidator_RejectsBadTopicMode(t *testing.T) { + if err := NewValidator().Validate([]byte(`{"topic_mode":"random"}`)); err == nil { + t.Error("expected rejection of unknown topic_mode") + } +} + +func TestValidator_AcceptsKnownTopicModes(t *testing.T) { + for _, mode := range []TopicMode{TopicModeOpaque, TopicModePath, TopicModeUser} { + body, _ := json.Marshal(Credentials{ + TopicMode: mode, + TopicSecret: "non-empty-just-in-case", // satisfies opaque-requires-secret + }) + if err := NewValidator().Validate(body); err != nil { + t.Errorf("topic_mode=%q rejected: %v", mode, err) + } + } +} + +func TestValidator_OpaqueRequiresSecret(t *testing.T) { + body := []byte(`{"topic_mode":"opaque"}`) + err := NewValidator().Validate(body) + if err == nil { + t.Fatal("expected error: opaque without secret") + } + if !strings.Contains(err.Error(), "topic_secret required") { + t.Errorf("error should mention topic_secret; got %v", err) + } +} + +func TestValidator_RedactNeverEchoesSecrets(t *testing.T) { + raw := []byte(`{ + "base_url":"https://push.example.com", + "auth_token":"SECRETAUTH", + "topic_mode":"opaque", + "topic_secret":"SECRETHASH" + }`) + out, err := NewValidator().Redact(raw) + if err != nil { + t.Fatalf("redact: %v", err) + } + enc, _ := json.Marshal(out) + if strings.Contains(string(enc), "SECRETAUTH") { + t.Errorf("redacted leaks auth_token: %s", enc) + } + if strings.Contains(string(enc), "SECRETHASH") { + t.Errorf("redacted leaks topic_secret: %s", enc) + } + if !strings.Contains(string(enc), `"has_auth_token":true`) { + t.Errorf("redacted should signal has_auth_token; got %s", enc) + } + if !strings.Contains(string(enc), `"has_topic_secret":true`) { + t.Errorf("redacted should signal has_topic_secret; got %s", enc) + } + if !strings.Contains(string(enc), "push.example.com") { + t.Errorf("redacted should preserve base_url; got %s", enc) + } +} + +func TestParseCredentials_RoundTrip(t *testing.T) { + raw, _ := json.Marshal(Credentials{ + BaseURL: "https://push.example.com", + AuthToken: "t-okt", + TopicMode: TopicModePath, + TopicSecret: "", + }) + c, err := ParseCredentials(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if c.BaseURL != "https://push.example.com" || c.AuthToken != "t-okt" { + t.Errorf("round-trip lost fields: %+v", c) + } + if c.TopicMode != TopicModePath { + t.Errorf("topic_mode lost: %q", c.TopicMode) + } +} diff --git a/sdk/package.json b/sdk/package.json index aa7864a..58579a9 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@debros/orama", - "version": "0.122.13", + "version": "0.122.14", "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", "type": "module", "main": "./dist/index.js",