feat(#72): full-privacy push — self-hosted ntfy + APNs-direct provider

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.<dnsZone> -> 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.
This commit is contained in:
anonpenguin23 2026-05-14 10:48:00 +03:00
parent 32a2a62e0d
commit 07638354d2
37 changed files with 4079 additions and 48 deletions

View File

@ -1 +1 @@
0.122.13 0.122.14

View File

@ -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.<dnsZone>` |
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_<KeyID>.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/<namespace>/<userId>` | Readable | Anyone enumerating topics sees IDs |
| `user` | `<userId>` | 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_<field>: 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 <your wallet JWT>
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 <your wallet JWT>
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": "<unique per-device ID>",
"provider": "apns", // or "ntfy" / "expo"
"token": "<hex APNs token | ntfy topic | 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/<namespace>/<userId>`.
---
## 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: "<wallet or 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 <your wallet JWT>
{
"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.<dnsZone>`
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.

View File

@ -25,6 +25,7 @@ require (
github.com/pion/turn/v4 v4.0.2 github.com/pion/turn/v4 v4.0.2
github.com/pion/webrtc/v4 v4.1.2 github.com/pion/webrtc/v4 v4.1.2
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 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/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.11.0 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/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gogo/protobuf v1.3.2 // 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/btree v1.1.3 // indirect
github.com/google/gopacket v1.1.19 // indirect github.com/google/gopacket v1.1.19 // indirect
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect

View File

@ -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-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-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-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/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 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU=
github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= 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.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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/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/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= 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/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/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/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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= 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/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-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-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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-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-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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 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-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-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-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-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.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -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:<base64(AES-256-GCM ciphertext)>
updated_at INTEGER NOT NULL, -- unix seconds
updated_by TEXT, -- audit: wallet/operator id
PRIMARY KEY (namespace, provider)
);

View File

@ -15,6 +15,7 @@ type Flags struct {
DryRun bool DryRun bool
SkipChecks bool SkipChecks bool
Nameserver bool // Make this node a nameserver (runs CoreDNS + Caddy) 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) JoinAddress string // HTTPS URL of existing node (e.g., https://node1.dbrs.space)
Token string // Invite token for joining (from orama invite) Token string // Invite token for joining (from orama invite)
ClusterSecret string // Deprecated: use --token instead 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.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.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.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 // Cluster join flags
fs.StringVar(&flags.JoinAddress, "join", "", "Join existing cluster via HTTPS URL (e.g. https://node1.dbrs.space)") fs.StringVar(&flags.JoinAddress, "join", "", "Join existing cluster via HTTPS URL (e.g. https://node1.dbrs.space)")

View File

@ -46,6 +46,7 @@ func NewOrchestrator(flags *Flags) (*Orchestrator, error) {
setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks) setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks)
setup.SetNameserver(flags.Nameserver) setup.SetNameserver(flags.Nameserver)
setup.SetNtfyHost(flags.NtfyHost)
// Configure Anyone mode // Configure Anyone mode
if flags.AnyoneRelay && flags.AnyoneClient { if flags.AnyoneRelay && flags.AnyoneClient {

View File

@ -11,7 +11,8 @@ type Flags struct {
Force bool Force bool
RestartServices bool RestartServices bool
SkipChecks 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 // Remote upgrade flags
Env string // Target environment for remote rolling upgrade 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 flag - use pointer to detect if explicitly set
nameserver := fs.Bool("nameserver", false, "Make this node a nameserver (uses saved preference if not specified)") 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 // Anyone flags
fs.BoolVar(&flags.AnyoneClient, "anyone-client", false, "Install Anyone as client-only (SOCKS5 proxy on port 9050, no relay)") 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 { if *nameserver {
flags.Nameserver = 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 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
}

View File

@ -38,8 +38,17 @@ func NewOrchestrator(flags *Flags) *Orchestrator {
isNameserver = *flags.Nameserver 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 := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks)
setup.SetNameserver(isNameserver) setup.SetNameserver(isNameserver)
setup.SetNtfyHost(isNtfyHost)
// Configure Anyone mode (explicit flags > saved preferences > auto-detect) // Configure Anyone mode (explicit flags > saved preferences > auto-detect)
// Explicit flags always win — they represent the user's current intent. // 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") 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.<dnsZone>)\n")
}
// Anyone client and relay are mutually exclusive — setting one clears the other. // Anyone client and relay are mutually exclusive — setting one clears the other.
if o.flags.AnyoneClient { if o.flags.AnyoneClient {
prefs.AnyoneClient = true prefs.AnyoneClient = true

View File

@ -23,6 +23,7 @@ type BinaryInstaller struct {
gateway *installers.GatewayInstaller gateway *installers.GatewayInstaller
coredns *installers.CoreDNSInstaller coredns *installers.CoreDNSInstaller
caddy *installers.CaddyInstaller caddy *installers.CaddyInstaller
ntfy *installers.NtfyInstaller // feature #72; installed only when EnableNtfy is set
} }
// NewBinaryInstaller creates a new binary installer // NewBinaryInstaller creates a new binary installer
@ -39,6 +40,7 @@ func NewBinaryInstaller(arch string, logWriter io.Writer) *BinaryInstaller {
gateway: installers.NewGatewayInstaller(arch, logWriter), gateway: installers.NewGatewayInstaller(arch, logWriter),
coredns: installers.NewCoreDNSInstaller(arch, logWriter, oramaHome), coredns: installers.NewCoreDNSInstaller(arch, logWriter, oramaHome),
caddy: installers.NewCaddyInstaller(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) return bi.caddy.Configure(domain, email, acmeEndpoint, baseDomain)
} }
// EnableCaddyNtfyProxy tells the Caddy installer to emit a reverse-
// proxy block for `hostname` → localhost:<NtfyListenPort> 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) // Mock system commands for testing (if needed)
var execCommand = exec.Command var execCommand = exec.Command

View File

@ -18,9 +18,15 @@ const (
// CaddyInstaller handles Caddy installation with custom DNS module // CaddyInstaller handles Caddy installation with custom DNS module
type CaddyInstaller struct { type CaddyInstaller struct {
*BaseInstaller *BaseInstaller
version string version string
oramaHome string oramaHome string
dnsModule string // Path to the orama DNS module source dnsModule string // Path to the orama DNS module source
// withNtfy, when set, causes generateCaddyfile to emit a reverse-
// proxy block for `push.<dnsZone>` → localhost:<NtfyListenPort>.
// 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 // 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 // IsInstalled checks if Caddy with orama DNS module is already installed
func (ci *CaddyInstaller) IsInstalled() bool { func (ci *CaddyInstaller) IsInstalled() bool {
caddyPath := "/usr/bin/caddy" 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)) 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) // HTTP catch-all fallback (handles remaining plain HTTP traffic)
sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n") sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n")

View File

@ -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.<dnsZone> to localhost:<NtfyListenPort>. 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.<dnsZone> 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)
}
}

View File

@ -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.<dnsZone>` 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:<NtfyListenPort> (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.<dnsZone>.
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<VER>/ntfy_<VER>_linux_<arch>.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_VER_linux_arch>/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: "<hex-sha256> <filename>") 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.<dnsZone> 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)
}

View File

@ -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 `*<file>` 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)
}
}

View File

@ -38,6 +38,7 @@ type ProductionSetup struct {
skipOptionalDeps bool skipOptionalDeps bool
skipResourceChecks bool skipResourceChecks bool
isNameserver bool // Whether this node is a nameserver (runs CoreDNS + Caddy) 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) isAnyoneClient bool // Whether this node runs Anyone as client-only (SOCKS5 proxy)
anyoneRelayConfig *AnyoneRelayConfig // Configuration for Anyone relay mode anyoneRelayConfig *AnyoneRelayConfig // Configuration for Anyone relay mode
privChecker *PrivilegeChecker privChecker *PrivilegeChecker
@ -135,6 +136,20 @@ func (ps *ProductionSetup) IsNameserver() bool {
return ps.isNameserver 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.<dnsZone>. 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 // SetAnyoneRelayConfig sets the Anyone relay configuration
func (ps *ProductionSetup) SetAnyoneRelayConfig(config *AnyoneRelayConfig) { func (ps *ProductionSetup) SetAnyoneRelayConfig(config *AnyoneRelayConfig) {
ps.anyoneRelayConfig = config ps.anyoneRelayConfig = config
@ -344,6 +359,14 @@ func (ps *ProductionSetup) installFromSource() error {
ps.logf(" ⚠️ Caddy install warning: %v", err) 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 // These are pre-built binary downloads (not Go compilation), always run them
if err := ps.binaryInstaller.InstallRQLite(); err != nil { if err := ps.binaryInstaller.InstallRQLite(); err != nil {
ps.logf(" ⚠️ RQLite install warning: %v", err) ps.logf(" ⚠️ RQLite install warning: %v", err)
@ -701,6 +724,23 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(peerAddresses []string, vpsIP s
} }
email := "admin@" + caddyDomain email := "admin@" + caddyDomain
acmeEndpoint := "http://localhost:6001/v1/internal/acme" 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.<dnsZone> 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 { if err := ps.binaryInstaller.ConfigureCaddy(caddyDomain, email, acmeEndpoint, baseDomain); err != nil {
ps.logf(" ⚠️ Caddy config warning: %v", err) ps.logf(" ⚠️ Caddy config warning: %v", err)
} else { } else {
@ -859,6 +899,13 @@ func (ps *ProductionSetup) Phase5CreateSystemdServices(enableHTTPS bool) error {
if _, err := os.Stat("/usr/bin/caddy"); err == nil { if _, err := os.Stat("/usr/bin/caddy"); err == nil {
services = append(services, "caddy.service") 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 { for _, svc := range services {
if err := ps.serviceController.EnableService(svc); err != nil { if err := ps.serviceController.EnableService(svc); err != nil {
ps.logf(" ⚠️ Failed to enable %s: %v", svc, err) 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.<dnsZone>), 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") ps.logf(" ✓ All services started")
return nil return nil
} }

View File

@ -11,6 +11,7 @@ import (
type NodePreferences struct { type NodePreferences struct {
Branch string `yaml:"branch"` Branch string `yaml:"branch"`
Nameserver bool `yaml:"nameserver"` Nameserver bool `yaml:"nameserver"`
NtfyHost bool `yaml:"ntfy_host"` // Feature #72: this node hosts self-hosted ntfy
AnyoneClient bool `yaml:"anyone_client"` AnyoneClient bool `yaml:"anyone_client"`
AnyoneRelay bool `yaml:"anyone_relay"` AnyoneRelay bool `yaml:"anyone_relay"`
AnyoneORPort int `yaml:"anyone_orport,omitempty"` // typically 9001 AnyoneORPort int `yaml:"anyone_orport,omitempty"` // typically 9001

View File

@ -20,6 +20,8 @@ import (
"github.com/DeBrosOfficial/network/pkg/olric" "github.com/DeBrosOfficial/network/pkg/olric"
"github.com/DeBrosOfficial/network/pkg/pubsub" "github.com/DeBrosOfficial/network/pkg/pubsub"
"github.com/DeBrosOfficial/network/pkg/push" "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" pushexpo "github.com/DeBrosOfficial/network/pkg/push/providers/expo"
pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy" pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy"
"github.com/DeBrosOfficial/network/pkg/rqlite" "github.com/DeBrosOfficial/network/pkg/rqlite"
@ -96,6 +98,13 @@ type Dependencies struct {
PushManager *push.Manager PushManager *push.Manager
PushConfigStore push.ConfigStore 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 // Authentication service
AuthService *auth.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 — // PushDispatcher (legacy) is set only when YAML defaults exist —
// kept for back-compat with code that hasn't migrated to Manager. // 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 { if err != nil {
// Non-fatal: log and continue. Functions calling push_send will get nil // Non-fatal: log and continue. Functions calling push_send will get nil
// (silent no-op) and HTTP /v1/push/* endpoints return 503. // (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.PushDeviceStore = pushStore
deps.PushManager = pushManager deps.PushManager = pushManager
deps.PushConfigStore = pushCfgStore deps.PushConfigStore = pushCfgStore
deps.PushCredentialsManager = pushCredManager
// Create host functions provider (allows functions to call Orama services) // Create host functions provider (allows functions to call Orama services)
hostFuncsCfg := hostfunctions.HostFunctionsConfig{ hostFuncsCfg := hostfunctions.HostFunctionsConfig{
@ -871,40 +881,109 @@ func buildPushDispatcher(
cfg *Config, cfg *Config,
db rqlite.Client, db rqlite.Client,
logger *logging.ColoredLogger, 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 == "" { if cfg.ClusterSecret == "" {
// Without the cluster secret we can't encrypt credentials at rest. // Without the cluster secret we can't encrypt credentials at rest.
// Disable the whole push subsystem; HTTP routes return 503. // 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) store, err := push.NewRqliteDeviceStore(db, cfg.ClusterSecret, logger.Logger)
if err != nil { 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) cfgStore, err := push.NewRqliteConfigStore(db, cfg.ClusterSecret, logger.Logger)
if err != nil { 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 // ProviderFactory turns a resolved Config into the right set of
// provider instances. Lives here in dependencies.go because this is // provider instances. Lives here in dependencies.go because this is
// the only place that imports both the manager package and the // the only place that imports both the manager package and the
// concrete provider sub-packages — keeps push core dep-cycle-free. // 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 var ps []push.PushProvider
if c.NtfyBaseURL != "" {
ps = append(ps, pushntfy.New(pushntfy.Config{ // ntfy provider — sourced from EITHER the new credentials store
BaseURL: c.NtfyBaseURL, // (#72, preferred) OR the legacy 026 push_config row. New table
AuthToken: c.NtfyAuthToken, // wins field-by-field; legacy fills any gap. ntfy is registered
}, logger.Logger)) // 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 != "" { if c.ExpoAccessToken != "" {
ps = append(ps, pushexpo.New(pushexpo.Config{ ps = append(ps, pushexpo.New(pushexpo.Config{
AccessToken: c.ExpoAccessToken, AccessToken: c.ExpoAccessToken,
}, logger.Logger)) }, 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 return ps
} }
@ -922,7 +1001,10 @@ func buildPushDispatcher(
var legacy *push.PushDispatcher var legacy *push.PushDispatcher
if !defaults.IsEmpty() { if !defaults.IsEmpty() {
legacy = push.New(store, logger.Logger) 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, NtfyBaseURL: defaults.NtfyBaseURL,
NtfyAuthToken: defaults.NtfyAuthToken, NtfyAuthToken: defaults.NtfyAuthToken,
ExpoAccessToken: defaults.ExpoAccessToken, ExpoAccessToken: defaults.ExpoAccessToken,
@ -941,5 +1023,5 @@ func buildPushDispatcher(
logger.ComponentInfo(logging.ComponentGeneral, logger.ComponentInfo(logging.ComponentGeneral,
"push subsystem initialized; tenants can self-serve via PUT /v1/push/config") "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
} }

View File

@ -391,6 +391,12 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
} else if deps.PushDispatcher != nil { } else if deps.PushDispatcher != nil {
gw.pushHandlers = pushhandlers.NewHandlers(deps.PushDispatcher, deps.PushDeviceStore, logger) 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 { if cfg.WebRTCEnabled && cfg.SFUPort > 0 {
gw.webrtcHandlers = webrtchandlers.NewWebRTCHandlers( gw.webrtcHandlers = webrtchandlers.NewWebRTCHandlers(

View File

@ -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
}

View File

@ -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_<each-field>` 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)
}
}
}

View File

@ -22,6 +22,7 @@ import (
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
"github.com/DeBrosOfficial/network/pkg/logging" "github.com/DeBrosOfficial/network/pkg/logging"
"github.com/DeBrosOfficial/network/pkg/push" "github.com/DeBrosOfficial/network/pkg/push"
"github.com/DeBrosOfficial/network/pkg/push/credentials"
) )
// Handlers serves the /v1/push/* HTTP endpoints. Construct via NewHandlers; // 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 — // configStore + manager may be nil on gateways with push fully disabled —
// the corresponding endpoints return 503. // the corresponding endpoints return 503.
type Handlers struct { type Handlers struct {
dispatcher *push.PushDispatcher dispatcher *push.PushDispatcher
manager *push.Manager manager *push.Manager
store push.PushDeviceStore store push.PushDeviceStore
configStore push.ConfigStore configStore push.ConfigStore
logger *logging.ColoredLogger credentialsManager *credentials.Manager // optional — feature #72 (set via SetCredentialsManager)
logger *logging.ColoredLogger
} }
// NewHandlers constructs a Handlers with the legacy single-namespace // NewHandlers constructs a Handlers with the legacy single-namespace

View File

@ -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") "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)
}

View File

@ -144,6 +144,16 @@ func (g *Gateway) Routes() http.Handler {
// instead of filing an ops ticket. Method dispatched in the handler. // instead of filing an ops ticket. Method dispatched in the handler.
mux.HandleFunc("/v1/push/config", g.pushConfigHandler) 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). // Per-namespace rate-limit configuration (feature #69).
// GET / PUT / DELETE — tenants self-serve their gateway-level rate // GET / PUT / DELETE — tenants self-serve their gateway-level rate
// limit override (requests_per_minute, burst) up to an operator-set // limit override (requests_per_minute, burst) up to an operator-set

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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 ""
}
}

View File

@ -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
}

View File

@ -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(<provider-name>, 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_<field>" 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_<field>` 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

View File

@ -26,6 +26,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sync" "sync"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -38,13 +39,18 @@ import (
// The factory is called once per fresh dispatcher build (cache miss). // The factory is called once per fresh dispatcher build (cache miss).
// Empty slice is allowed and means "this config produces no providers"; // Empty slice is allowed and means "this config produces no providers";
// Manager treats that as ErrPushNotConfigured. // 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 // ErrPushNotConfigured is returned by Send when the namespace has no
// per-namespace config AND the gateway has no fallback defaults — i.e. // per-namespace config AND the gateway has no fallback defaults — i.e.
// nothing to send through. Distinguish from ErrNoDevices (different // nothing to send through. Distinguish from ErrNoDevices (different
// failure mode). // 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 // 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 // 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 // Manager is the top-level push entry point. Build with NewManager and
// hand out via the gateway's dependencies. Safe for concurrent use. // 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 { type Manager struct {
store ConfigStore store ConfigStore
devices PushDeviceStore devices PushDeviceStore
defaults Defaults defaults Defaults
factory ProviderFactory factory ProviderFactory
logger *zap.Logger logger *zap.Logger
ttl time.Duration // configurable for tests
// cache LRU of namespace → built dispatcher. // cache LRU of namespace → built dispatcher.
mu sync.Mutex mu sync.Mutex
@ -81,6 +102,7 @@ type Manager struct {
type cacheEntry struct { type cacheEntry struct {
namespace string namespace string
dispatcher *PushDispatcher dispatcher *PushDispatcher
builtAt time.Time
} }
// defaultCacheCap caps how many namespaces' dispatchers we hold in memory. // defaultCacheCap caps how many namespaces' dispatchers we hold in memory.
@ -88,6 +110,12 @@ type cacheEntry struct {
// memory under abuse. // memory under abuse.
const defaultCacheCap = 256 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 // NewManager constructs a Manager with the given device store, config
// store, fallback Defaults, and ProviderFactory. // store, fallback Defaults, and ProviderFactory.
// //
@ -105,12 +133,26 @@ func NewManager(devices PushDeviceStore, store ConfigStore, defaults Defaults, f
defaults: defaults, defaults: defaults,
factory: factory, factory: factory,
logger: logger, logger: logger,
ttl: cacheEntryTTL,
cache: make(map[string]*list.Element, defaultCacheCap), cache: make(map[string]*list.Element, defaultCacheCap),
lru: list.New(), lru: list.New(),
cacheCap: defaultCacheCap, 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 // SendToUser dispatches a push to every device registered for the user
// in the given namespace. Looks up per-namespace config (or falls back // in the given namespace. Looks up per-namespace config (or falls back
// to defaults), builds the appropriate dispatcher, and sends. // 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 // 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) { 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() m.mu.Lock()
if elem, ok := m.cache[namespace]; ok { if elem, ok := m.cache[namespace]; ok {
m.lru.MoveToFront(elem)
entry := elem.Value.(*cacheEntry) entry := elem.Value.(*cacheEntry)
m.mu.Unlock() if time.Since(entry.builtAt) < m.ttl {
return entry.dispatcher, nil 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() m.mu.Unlock()
@ -186,10 +235,16 @@ func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDis
// Insert into cache (eviction if at capacity). // Insert into cache (eviction if at capacity).
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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 { if elem, ok := m.cache[namespace]; ok {
m.lru.MoveToFront(elem) entry := elem.Value.(*cacheEntry)
return elem.Value.(*cacheEntry).dispatcher, nil 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 { if m.lru.Len() >= m.cacheCap {
oldest := m.lru.Back() oldest := m.lru.Back()
@ -199,7 +254,7 @@ func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDis
delete(m.cache, old.namespace) 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) m.cache[namespace] = m.lru.PushFront(entry)
return d, nil 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 { if m.factory == nil {
// Defensive: a Manager built without a factory can't produce // Defensive: a Manager built without a factory can't produce
// providers. Programmer error; surface explicitly. // providers. Programmer error; surface explicitly.
return nil, fmt.Errorf("manager: no provider factory configured") 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 { if len(providers) == 0 {
return nil, ErrPushNotConfigured return nil, ErrPushNotConfigured
} }

View File

@ -77,7 +77,7 @@ func TestManager_namespace_with_no_config_uses_defaults(t *testing.T) {
defaults := Defaults{NtfyBaseURL: "http://default-ntfy"} defaults := Defaults{NtfyBaseURL: "http://default-ntfy"}
var providerCalls atomic.Int32 var providerCalls atomic.Int32
factory := func(c Config) []PushProvider { factory := func(_ context.Context, c Config) []PushProvider {
providerCalls.Add(1) providerCalls.Add(1)
// Verify the manager passed defaults through to the factory. // Verify the manager passed defaults through to the factory.
if c.NtfyBaseURL != "http://default-ntfy" { 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"} defaults := Defaults{NtfyBaseURL: "http://default-ntfy"}
var seenURL string var seenURL string
factory := func(c Config) []PushProvider { factory := func(_ context.Context, c Config) []PushProvider {
seenURL = c.NtfyBaseURL seenURL = c.NtfyBaseURL
return []PushProvider{&managerFakeProvider{name: "ntfy"}} 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) { func TestManager_no_config_no_defaults_returns_ErrPushNotConfigured(t *testing.T) {
store := newFakeConfigStore() store := newFakeConfigStore()
factory := func(_ Config) []PushProvider { return nil } factory := func(_ context.Context, _ Config) []PushProvider { return nil }
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop()) m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
_, err := m.dispatcherFor(context.Background(), "ns-A") _, 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"}) store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "u"})
var factoryCalls atomic.Int32 var factoryCalls atomic.Int32
factory := func(_ Config) []PushProvider { factory := func(_ context.Context, _ Config) []PushProvider {
factoryCalls.Add(1) factoryCalls.Add(1)
return []PushProvider{&managerFakeProvider{name: "ntfy"}} 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"}) store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "v1"})
var seenURLs []string var seenURLs []string
factory := func(c Config) []PushProvider { factory := func(_ context.Context, c Config) []PushProvider {
seenURLs = append(seenURLs, c.NtfyBaseURL) seenURLs = append(seenURLs, c.NtfyBaseURL)
return []PushProvider{&managerFakeProvider{name: "ntfy"}} return []PushProvider{&managerFakeProvider{name: "ntfy"}}
} }
@ -190,7 +190,7 @@ func TestManager_per_namespace_isolation(t *testing.T) {
urlByNS := make(map[string]string) urlByNS := make(map[string]string)
var mu sync.Mutex var mu sync.Mutex
factory := func(c Config) []PushProvider { factory := func(_ context.Context, c Config) []PushProvider {
mu.Lock() mu.Lock()
urlByNS[c.Namespace] = c.NtfyBaseURL urlByNS[c.Namespace] = c.NtfyBaseURL
mu.Unlock() mu.Unlock()
@ -238,7 +238,7 @@ func TestManager_concurrent_dispatcherFor_no_race(t *testing.T) {
// Run with -race. // Run with -race.
store := newFakeConfigStore() store := newFakeConfigStore()
store.Upsert(context.Background(), Config{Namespace: "ns", NtfyBaseURL: "u"}) 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()) m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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/<namespace>/<userId>" as the topic.
// Readable / debuggable; exposes which users have push enabled to
// anyone enumerating topics.
TopicModePath TopicMode = "path"
// TopicModeUser uses just "<userId>" 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
}

View File

@ -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)
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@debros/orama", "name": "@debros/orama",
"version": "0.122.13", "version": "0.122.14",
"description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",