mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 21:54:14 +00:00
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:
parent
32a2a62e0d
commit
07638354d2
367
core/docs/PUSH_NOTIFICATIONS.md
Normal file
367
core/docs/PUSH_NOTIFICATIONS.md
Normal 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.
|
||||
@ -25,6 +25,7 @@ require (
|
||||
github.com/pion/turn/v4 v4.0.2
|
||||
github.com/pion/webrtc/v4 v4.1.2
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||
github.com/sideshow/apns2 v0.25.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
@ -65,6 +66,7 @@ require (
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
|
||||
|
||||
@ -16,6 +16,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||
github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU=
|
||||
github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc=
|
||||
@ -134,6 +135,9 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
|
||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
@ -491,6 +495,8 @@ github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
|
||||
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
|
||||
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
|
||||
github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
@ -571,6 +577,7 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE=
|
||||
golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw=
|
||||
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@ -617,6 +624,7 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
@ -667,6 +675,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
34
core/migrations/028_namespace_push_credentials.sql
Normal file
34
core/migrations/028_namespace_push_credentials.sql
Normal 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)
|
||||
);
|
||||
@ -15,6 +15,7 @@ type Flags struct {
|
||||
DryRun bool
|
||||
SkipChecks bool
|
||||
Nameserver bool // Make this node a nameserver (runs CoreDNS + Caddy)
|
||||
NtfyHost bool // Host the self-hosted ntfy server on this node (feature #72)
|
||||
JoinAddress string // HTTPS URL of existing node (e.g., https://node1.dbrs.space)
|
||||
Token string // Invite token for joining (from orama invite)
|
||||
ClusterSecret string // Deprecated: use --token instead
|
||||
@ -64,6 +65,7 @@ func ParseFlags(args []string) (*Flags, error) {
|
||||
fs.BoolVar(&flags.DryRun, "dry-run", false, "Show what would be done without making changes")
|
||||
fs.BoolVar(&flags.SkipChecks, "skip-checks", false, "Skip minimum resource checks (RAM/CPU)")
|
||||
fs.BoolVar(&flags.Nameserver, "nameserver", false, "Make this node a nameserver (runs CoreDNS + Caddy)")
|
||||
fs.BoolVar(&flags.NtfyHost, "with-ntfy", false, "Host the self-hosted ntfy server (feature #72; usually colocated with --nameserver on devnet)")
|
||||
|
||||
// Cluster join flags
|
||||
fs.StringVar(&flags.JoinAddress, "join", "", "Join existing cluster via HTTPS URL (e.g. https://node1.dbrs.space)")
|
||||
|
||||
@ -46,6 +46,7 @@ func NewOrchestrator(flags *Flags) (*Orchestrator, error) {
|
||||
|
||||
setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks)
|
||||
setup.SetNameserver(flags.Nameserver)
|
||||
setup.SetNtfyHost(flags.NtfyHost)
|
||||
|
||||
// Configure Anyone mode
|
||||
if flags.AnyoneRelay && flags.AnyoneClient {
|
||||
|
||||
@ -11,7 +11,8 @@ type Flags struct {
|
||||
Force bool
|
||||
RestartServices bool
|
||||
SkipChecks bool
|
||||
Nameserver *bool // Pointer so we can detect if explicitly set vs default
|
||||
Nameserver *bool // Pointer so we can detect if explicitly set vs default
|
||||
NtfyHost *bool // Feature #72: nil = use saved preference; non-nil = explicit override
|
||||
|
||||
// Remote upgrade flags
|
||||
Env string // Target environment for remote rolling upgrade
|
||||
@ -50,6 +51,8 @@ func ParseFlags(args []string) (*Flags, error) {
|
||||
|
||||
// Nameserver flag - use pointer to detect if explicitly set
|
||||
nameserver := fs.Bool("nameserver", false, "Make this node a nameserver (uses saved preference if not specified)")
|
||||
// Ntfy host flag (feature #72) — same pattern, sticks via preferences.yaml.
|
||||
ntfyHost := fs.Bool("with-ntfy", false, "Host the self-hosted ntfy server on this node (uses saved preference if not specified)")
|
||||
|
||||
// Anyone flags
|
||||
fs.BoolVar(&flags.AnyoneClient, "anyone-client", false, "Install Anyone as client-only (SOCKS5 proxy on port 9050, no relay)")
|
||||
@ -75,6 +78,25 @@ func ParseFlags(args []string) (*Flags, error) {
|
||||
if *nameserver {
|
||||
flags.Nameserver = nameserver
|
||||
}
|
||||
// Set ntfy_host only when explicitly passed (default false != "use saved").
|
||||
// Without explicit set, the orchestrator reads the saved preference.
|
||||
if isFlagPassed(fs, "with-ntfy") {
|
||||
flags.NtfyHost = ntfyHost
|
||||
}
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
// isFlagPassed reports whether the named flag was explicitly set on
|
||||
// the command line, not just defaulted. Used to distinguish "user
|
||||
// didn't say anything; honor saved preference" from "user wrote
|
||||
// --with-ntfy=false; turn it OFF".
|
||||
func isFlagPassed(fs *flag.FlagSet, name string) bool {
|
||||
passed := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
passed = true
|
||||
}
|
||||
})
|
||||
return passed
|
||||
}
|
||||
|
||||
@ -38,8 +38,17 @@ func NewOrchestrator(flags *Flags) *Orchestrator {
|
||||
isNameserver = *flags.Nameserver
|
||||
}
|
||||
|
||||
// Feature #72: ntfy-host preference survives upgrades the same way
|
||||
// nameserver does — loaded from preferences.yaml unless the upgrade
|
||||
// flags override it explicitly.
|
||||
isNtfyHost := prefs.NtfyHost
|
||||
if flags.NtfyHost != nil {
|
||||
isNtfyHost = *flags.NtfyHost
|
||||
}
|
||||
|
||||
setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, flags.SkipChecks)
|
||||
setup.SetNameserver(isNameserver)
|
||||
setup.SetNtfyHost(isNtfyHost)
|
||||
|
||||
// Configure Anyone mode (explicit flags > saved preferences > auto-detect)
|
||||
// Explicit flags always win — they represent the user's current intent.
|
||||
@ -211,6 +220,15 @@ func (o *Orchestrator) handleBranchPreferences() error {
|
||||
fmt.Printf(" Nameserver mode: enabled (CoreDNS + Caddy)\n")
|
||||
}
|
||||
|
||||
// If ntfy-host was explicitly provided, persist it (feature #72).
|
||||
if o.flags.NtfyHost != nil {
|
||||
prefs.NtfyHost = *o.flags.NtfyHost
|
||||
prefsChanged = true
|
||||
}
|
||||
if o.setup.IsNtfyHost() {
|
||||
fmt.Printf(" ntfy host: enabled (self-hosted ntfy on push.<dnsZone>)\n")
|
||||
}
|
||||
|
||||
// Anyone client and relay are mutually exclusive — setting one clears the other.
|
||||
if o.flags.AnyoneClient {
|
||||
prefs.AnyoneClient = true
|
||||
|
||||
@ -23,6 +23,7 @@ type BinaryInstaller struct {
|
||||
gateway *installers.GatewayInstaller
|
||||
coredns *installers.CoreDNSInstaller
|
||||
caddy *installers.CaddyInstaller
|
||||
ntfy *installers.NtfyInstaller // feature #72; installed only when EnableNtfy is set
|
||||
}
|
||||
|
||||
// NewBinaryInstaller creates a new binary installer
|
||||
@ -39,6 +40,7 @@ func NewBinaryInstaller(arch string, logWriter io.Writer) *BinaryInstaller {
|
||||
gateway: installers.NewGatewayInstaller(arch, logWriter),
|
||||
coredns: installers.NewCoreDNSInstaller(arch, logWriter, oramaHome),
|
||||
caddy: installers.NewCaddyInstaller(arch, logWriter, oramaHome),
|
||||
ntfy: installers.NewNtfyInstaller(arch, logWriter),
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,6 +149,27 @@ func (bi *BinaryInstaller) ConfigureCaddy(domain string, email string, acmeEndpo
|
||||
return bi.caddy.Configure(domain, email, acmeEndpoint, baseDomain)
|
||||
}
|
||||
|
||||
// EnableCaddyNtfyProxy tells the Caddy installer to emit a reverse-
|
||||
// proxy block for `hostname` → localhost:<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)
|
||||
var execCommand = exec.Command
|
||||
|
||||
|
||||
@ -18,9 +18,15 @@ const (
|
||||
// CaddyInstaller handles Caddy installation with custom DNS module
|
||||
type CaddyInstaller struct {
|
||||
*BaseInstaller
|
||||
version string
|
||||
oramaHome string
|
||||
dnsModule string // Path to the orama DNS module source
|
||||
version string
|
||||
oramaHome string
|
||||
dnsModule string // Path to the orama DNS module source
|
||||
|
||||
// withNtfy, when set, causes generateCaddyfile to emit a reverse-
|
||||
// proxy block for `push.<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
|
||||
@ -33,6 +39,19 @@ func NewCaddyInstaller(arch string, logWriter io.Writer, oramaHome string) *Cadd
|
||||
}
|
||||
}
|
||||
|
||||
// EnableNtfyProxy tells the Caddy installer to emit a reverse-proxy
|
||||
// block for the self-hosted ntfy server (feature #72). hostname is the
|
||||
// public fully-qualified domain — e.g. "push.dbrs.space" — that Caddy
|
||||
// will obtain a Let's Encrypt cert for and route to the local ntfy
|
||||
// server on NtfyListenPort.
|
||||
//
|
||||
// Must be called BEFORE Configure so the generated Caddyfile includes
|
||||
// the block.
|
||||
func (ci *CaddyInstaller) EnableNtfyProxy(hostname string) {
|
||||
ci.withNtfy = true
|
||||
ci.ntfyHostname = hostname
|
||||
}
|
||||
|
||||
// IsInstalled checks if Caddy with orama DNS module is already installed
|
||||
func (ci *CaddyInstaller) IsInstalled() bool {
|
||||
caddyPath := "/usr/bin/caddy"
|
||||
@ -420,6 +439,16 @@ func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint, baseDom
|
||||
sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", baseDomain))
|
||||
}
|
||||
|
||||
// Self-hosted ntfy reverse-proxy (feature #72). Emitted only when
|
||||
// the orchestrator has called EnableNtfyProxy on this installer —
|
||||
// i.e. this node was selected to host ntfy. The hostname is its
|
||||
// own block so the cert lives separately from the namespace gateway
|
||||
// cert (different rotation cadence, different blast radius).
|
||||
if ci.withNtfy && ci.ntfyHostname != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n%s {\n%s\n reverse_proxy localhost:%d\n}\n",
|
||||
ci.ntfyHostname, tlsBlock, NtfyListenPort))
|
||||
}
|
||||
|
||||
// HTTP catch-all fallback (handles remaining plain HTTP traffic)
|
||||
sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n")
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
431
core/pkg/environments/production/installers/ntfy.go
Normal file
431
core/pkg/environments/production/installers/ntfy.go
Normal 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)
|
||||
}
|
||||
130
core/pkg/environments/production/installers/ntfy_test.go
Normal file
130
core/pkg/environments/production/installers/ntfy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,7 @@ type ProductionSetup struct {
|
||||
skipOptionalDeps bool
|
||||
skipResourceChecks bool
|
||||
isNameserver bool // Whether this node is a nameserver (runs CoreDNS + Caddy)
|
||||
isNtfyHost bool // Feature #72: whether this node hosts the self-hosted ntfy server
|
||||
isAnyoneClient bool // Whether this node runs Anyone as client-only (SOCKS5 proxy)
|
||||
anyoneRelayConfig *AnyoneRelayConfig // Configuration for Anyone relay mode
|
||||
privChecker *PrivilegeChecker
|
||||
@ -135,6 +136,20 @@ func (ps *ProductionSetup) IsNameserver() bool {
|
||||
return ps.isNameserver
|
||||
}
|
||||
|
||||
// SetNtfyHost flags this node as the host for the self-hosted ntfy
|
||||
// server (feature #72). When set, Phase 2 installs ntfy and Phase 4
|
||||
// generates /etc/ntfy/server.yml plus a Caddy reverse-proxy block for
|
||||
// push.<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
|
||||
func (ps *ProductionSetup) SetAnyoneRelayConfig(config *AnyoneRelayConfig) {
|
||||
ps.anyoneRelayConfig = config
|
||||
@ -344,6 +359,14 @@ func (ps *ProductionSetup) installFromSource() error {
|
||||
ps.logf(" ⚠️ Caddy install warning: %v", err)
|
||||
}
|
||||
|
||||
// Install ntfy only on nodes flagged as the ntfy host (feature #72).
|
||||
// On devnet this is ns1; on production it can be a dedicated node.
|
||||
if ps.isNtfyHost {
|
||||
if err := ps.binaryInstaller.InstallNtfy(); err != nil {
|
||||
ps.logf(" ⚠️ ntfy install warning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// These are pre-built binary downloads (not Go compilation), always run them
|
||||
if err := ps.binaryInstaller.InstallRQLite(); err != nil {
|
||||
ps.logf(" ⚠️ RQLite install warning: %v", err)
|
||||
@ -701,6 +724,23 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(peerAddresses []string, vpsIP s
|
||||
}
|
||||
email := "admin@" + caddyDomain
|
||||
acmeEndpoint := "http://localhost:6001/v1/internal/acme"
|
||||
|
||||
// Self-hosted ntfy (feature #72): when this node hosts ntfy,
|
||||
// (a) tell the Caddy installer to emit a push.<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 {
|
||||
ps.logf(" ⚠️ Caddy config warning: %v", err)
|
||||
} else {
|
||||
@ -859,6 +899,13 @@ func (ps *ProductionSetup) Phase5CreateSystemdServices(enableHTTPS bool) error {
|
||||
if _, err := os.Stat("/usr/bin/caddy"); err == nil {
|
||||
services = append(services, "caddy.service")
|
||||
}
|
||||
// Add ntfy when this node hosts the self-hosted ntfy server (#72).
|
||||
// The unit file is written by installers/ntfy.go::writeSystemdUnit.
|
||||
if ps.isNtfyHost {
|
||||
if _, err := os.Stat("/usr/local/bin/ntfy"); err == nil {
|
||||
services = append(services, "ntfy.service")
|
||||
}
|
||||
}
|
||||
for _, svc := range services {
|
||||
if err := ps.serviceController.EnableService(svc); err != nil {
|
||||
ps.logf(" ⚠️ Failed to enable %s: %v", svc, err)
|
||||
@ -935,6 +982,19 @@ func (ps *ProductionSetup) Phase5CreateSystemdServices(enableHTTPS bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Start ntfy when this node hosts it (#72). Caddy must already be
|
||||
// up (it terminates TLS for push.<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")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
type NodePreferences struct {
|
||||
Branch string `yaml:"branch"`
|
||||
Nameserver bool `yaml:"nameserver"`
|
||||
NtfyHost bool `yaml:"ntfy_host"` // Feature #72: this node hosts self-hosted ntfy
|
||||
AnyoneClient bool `yaml:"anyone_client"`
|
||||
AnyoneRelay bool `yaml:"anyone_relay"`
|
||||
AnyoneORPort int `yaml:"anyone_orport,omitempty"` // typically 9001
|
||||
|
||||
@ -20,6 +20,8 @@ import (
|
||||
"github.com/DeBrosOfficial/network/pkg/olric"
|
||||
"github.com/DeBrosOfficial/network/pkg/pubsub"
|
||||
"github.com/DeBrosOfficial/network/pkg/push"
|
||||
pushcreds "github.com/DeBrosOfficial/network/pkg/push/credentials"
|
||||
pushapns "github.com/DeBrosOfficial/network/pkg/push/providers/apns"
|
||||
pushexpo "github.com/DeBrosOfficial/network/pkg/push/providers/expo"
|
||||
pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy"
|
||||
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||
@ -96,6 +98,13 @@ type Dependencies struct {
|
||||
PushManager *push.Manager
|
||||
PushConfigStore push.ConfigStore
|
||||
|
||||
// PushCredentialsManager owns per-namespace, per-provider push
|
||||
// credentials (feature #72). Used by provider factories to look up
|
||||
// the right credential at send time, and by the HTTP credentials
|
||||
// handlers for tenant self-service PUT/GET/DELETE. Nil when the
|
||||
// cluster secret is unavailable.
|
||||
PushCredentialsManager *pushcreds.Manager
|
||||
|
||||
// Authentication service
|
||||
AuthService *auth.Service
|
||||
}
|
||||
@ -480,7 +489,7 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
|
||||
//
|
||||
// PushDispatcher (legacy) is set only when YAML defaults exist —
|
||||
// kept for back-compat with code that hasn't migrated to Manager.
|
||||
pushDispatcher, pushStore, pushManager, pushCfgStore, err := buildPushDispatcher(cfg, deps.ORMClient, logger)
|
||||
pushDispatcher, pushStore, pushManager, pushCfgStore, pushCredManager, err := buildPushDispatcher(cfg, deps.ORMClient, logger)
|
||||
if err != nil {
|
||||
// Non-fatal: log and continue. Functions calling push_send will get nil
|
||||
// (silent no-op) and HTTP /v1/push/* endpoints return 503.
|
||||
@ -491,6 +500,7 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
|
||||
deps.PushDeviceStore = pushStore
|
||||
deps.PushManager = pushManager
|
||||
deps.PushConfigStore = pushCfgStore
|
||||
deps.PushCredentialsManager = pushCredManager
|
||||
|
||||
// Create host functions provider (allows functions to call Orama services)
|
||||
hostFuncsCfg := hostfunctions.HostFunctionsConfig{
|
||||
@ -871,40 +881,109 @@ func buildPushDispatcher(
|
||||
cfg *Config,
|
||||
db rqlite.Client,
|
||||
logger *logging.ColoredLogger,
|
||||
) (*push.PushDispatcher, push.PushDeviceStore, *push.Manager, push.ConfigStore, error) {
|
||||
) (*push.PushDispatcher, push.PushDeviceStore, *push.Manager, push.ConfigStore, *pushcreds.Manager, error) {
|
||||
if cfg.ClusterSecret == "" {
|
||||
// Without the cluster secret we can't encrypt credentials at rest.
|
||||
// Disable the whole push subsystem; HTTP routes return 503.
|
||||
return nil, nil, nil, nil, nil
|
||||
return nil, nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
store, err := push.NewRqliteDeviceStore(db, cfg.ClusterSecret, logger.Logger)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("init push device store: %w", err)
|
||||
return nil, nil, nil, nil, nil, fmt.Errorf("init push device store: %w", err)
|
||||
}
|
||||
|
||||
cfgStore, err := push.NewRqliteConfigStore(db, cfg.ClusterSecret, logger.Logger)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("init push config store: %w", err)
|
||||
return nil, nil, nil, nil, nil, fmt.Errorf("init push config store: %w", err)
|
||||
}
|
||||
|
||||
// Per-namespace, per-provider credentials (feature #72). Generic
|
||||
// store — used by APNs, ntfy (post-migration), FCM-direct (future).
|
||||
// Provider packages register their Validator at gateway startup
|
||||
// (see pushcreds.Register calls below).
|
||||
credStore, err := pushcreds.NewRqliteStore(db, cfg.ClusterSecret, logger.Logger)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, fmt.Errorf("init push credentials store: %w", err)
|
||||
}
|
||||
credManager := pushcreds.NewManager(credStore, logger.Logger)
|
||||
|
||||
// Register the Validators that this gateway accepts. Each provider
|
||||
// package owns its own JSON schema + redactor; we tell the
|
||||
// credentials package which ones to allow at PUT/GET time. Adding a
|
||||
// new provider (FCM-direct, SMS, etc.) means a single new Register
|
||||
// call here — no other code needs to know.
|
||||
pushcreds.Register(pushapns.NewValidator())
|
||||
pushcreds.Register(pushntfy.NewValidator())
|
||||
|
||||
// ProviderFactory turns a resolved Config into the right set of
|
||||
// provider instances. Lives here in dependencies.go because this is
|
||||
// the only place that imports both the manager package and the
|
||||
// concrete provider sub-packages — keeps push core dep-cycle-free.
|
||||
factory := func(c push.Config) []push.PushProvider {
|
||||
//
|
||||
// Per-namespace credentialed providers (APNs — feature #72) are
|
||||
// constructed here by consulting the credentials manager. If a
|
||||
// namespace has stored credentials for a provider, that provider is
|
||||
// instantiated with those credentials and registered in the
|
||||
// dispatcher; otherwise it's omitted.
|
||||
factory := func(ctx context.Context, c push.Config) []push.PushProvider {
|
||||
var ps []push.PushProvider
|
||||
if c.NtfyBaseURL != "" {
|
||||
ps = append(ps, pushntfy.New(pushntfy.Config{
|
||||
BaseURL: c.NtfyBaseURL,
|
||||
AuthToken: c.NtfyAuthToken,
|
||||
}, logger.Logger))
|
||||
|
||||
// ntfy provider — sourced from EITHER the new credentials store
|
||||
// (#72, preferred) OR the legacy 026 push_config row. New table
|
||||
// wins field-by-field; legacy fills any gap. ntfy is registered
|
||||
// only if a BaseURL ends up set; auth_token alone is useless
|
||||
// without a server to point at.
|
||||
ntfyCfg := pushntfy.Config{
|
||||
BaseURL: c.NtfyBaseURL,
|
||||
AuthToken: c.NtfyAuthToken,
|
||||
}
|
||||
if c.Namespace != "" && credManager != nil {
|
||||
if cred, err := credManager.Get(ctx, c.Namespace, "ntfy"); err == nil && cred != nil {
|
||||
if ov, perr := pushntfy.ParseCredentials(cred.JSON); perr == nil {
|
||||
if ov.BaseURL != "" {
|
||||
ntfyCfg.BaseURL = ov.BaseURL
|
||||
}
|
||||
if ov.AuthToken != "" {
|
||||
ntfyCfg.AuthToken = ov.AuthToken
|
||||
}
|
||||
} else {
|
||||
logger.ComponentWarn(logging.ComponentGeneral,
|
||||
"ntfy credentials parse failed",
|
||||
zap.String("namespace", c.Namespace),
|
||||
zap.Error(perr))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ntfyCfg.BaseURL != "" {
|
||||
ps = append(ps, pushntfy.New(ntfyCfg, logger.Logger))
|
||||
}
|
||||
if c.ExpoAccessToken != "" {
|
||||
ps = append(ps, pushexpo.New(pushexpo.Config{
|
||||
AccessToken: c.ExpoAccessToken,
|
||||
}, logger.Logger))
|
||||
}
|
||||
// APNs is fully credentialed — no YAML fallback. The presence of
|
||||
// per-namespace credentials is the trigger.
|
||||
if c.Namespace != "" && credManager != nil {
|
||||
if cred, err := credManager.Get(ctx, c.Namespace, "apns"); err == nil && cred != nil {
|
||||
if apnsCfg, perr := pushapns.ParseCredentials(cred.JSON); perr == nil {
|
||||
if provider, nerr := pushapns.New(apnsCfg, logger.Logger); nerr == nil {
|
||||
ps = append(ps, provider)
|
||||
} else {
|
||||
logger.ComponentWarn(logging.ComponentGeneral,
|
||||
"apns provider construction failed",
|
||||
zap.String("namespace", c.Namespace),
|
||||
zap.Error(nerr))
|
||||
}
|
||||
} else {
|
||||
logger.ComponentWarn(logging.ComponentGeneral,
|
||||
"apns credentials parse failed",
|
||||
zap.String("namespace", c.Namespace),
|
||||
zap.Error(perr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
@ -922,7 +1001,10 @@ func buildPushDispatcher(
|
||||
var legacy *push.PushDispatcher
|
||||
if !defaults.IsEmpty() {
|
||||
legacy = push.New(store, logger.Logger)
|
||||
for _, p := range factory(push.Config{
|
||||
// Boot-time construction: no request context yet. Use Background
|
||||
// — the credential lookups here are fast (in-memory cache miss
|
||||
// reads rqlite once) and cancellation is irrelevant during boot.
|
||||
for _, p := range factory(context.Background(), push.Config{
|
||||
NtfyBaseURL: defaults.NtfyBaseURL,
|
||||
NtfyAuthToken: defaults.NtfyAuthToken,
|
||||
ExpoAccessToken: defaults.ExpoAccessToken,
|
||||
@ -941,5 +1023,5 @@ func buildPushDispatcher(
|
||||
logger.ComponentInfo(logging.ComponentGeneral,
|
||||
"push subsystem initialized; tenants can self-serve via PUT /v1/push/config")
|
||||
|
||||
return legacy, store, manager, cfgStore, nil
|
||||
return legacy, store, manager, cfgStore, credManager, nil
|
||||
}
|
||||
|
||||
@ -391,6 +391,12 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
} else if deps.PushDispatcher != nil {
|
||||
gw.pushHandlers = pushhandlers.NewHandlers(deps.PushDispatcher, deps.PushDeviceStore, logger)
|
||||
}
|
||||
// Wire the per-provider credentials manager (feature #72) if push is
|
||||
// up. The handler nil-checks the manager internally so this is safe
|
||||
// even when push is partially configured.
|
||||
if gw.pushHandlers != nil && deps.PushCredentialsManager != nil {
|
||||
gw.pushHandlers.SetCredentialsManager(deps.PushCredentialsManager)
|
||||
}
|
||||
|
||||
if cfg.WebRTCEnabled && cfg.SFUPort > 0 {
|
||||
gw.webrtcHandlers = webrtchandlers.NewWebRTCHandlers(
|
||||
|
||||
341
core/pkg/gateway/handlers/push/credentials_handler.go
Normal file
341
core/pkg/gateway/handlers/push/credentials_handler.go
Normal 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
|
||||
}
|
||||
|
||||
380
core/pkg/gateway/handlers/push/credentials_handler_test.go
Normal file
380
core/pkg/gateway/handlers/push/credentials_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
|
||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||
"github.com/DeBrosOfficial/network/pkg/push"
|
||||
"github.com/DeBrosOfficial/network/pkg/push/credentials"
|
||||
)
|
||||
|
||||
// Handlers serves the /v1/push/* HTTP endpoints. Construct via NewHandlers;
|
||||
@ -36,11 +37,12 @@ import (
|
||||
// configStore + manager may be nil on gateways with push fully disabled —
|
||||
// the corresponding endpoints return 503.
|
||||
type Handlers struct {
|
||||
dispatcher *push.PushDispatcher
|
||||
manager *push.Manager
|
||||
store push.PushDeviceStore
|
||||
configStore push.ConfigStore
|
||||
logger *logging.ColoredLogger
|
||||
dispatcher *push.PushDispatcher
|
||||
manager *push.Manager
|
||||
store push.PushDeviceStore
|
||||
configStore push.ConfigStore
|
||||
credentialsManager *credentials.Manager // optional — feature #72 (set via SetCredentialsManager)
|
||||
logger *logging.ColoredLogger
|
||||
}
|
||||
|
||||
// NewHandlers constructs a Handlers with the legacy single-namespace
|
||||
|
||||
@ -86,3 +86,30 @@ func (g *Gateway) pushConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"method not allowed: use GET to read, PUT to update, or DELETE to clear")
|
||||
}
|
||||
}
|
||||
|
||||
// pushCredentialsSummaryHandler — GET /v1/namespace/push-credentials.
|
||||
// Returns the list of providers with credentials stored AND the list of
|
||||
// providers this gateway supports (feature #72). 503 when push isn't
|
||||
// configured at all.
|
||||
func (g *Gateway) pushCredentialsSummaryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if g.pushHandlers == nil {
|
||||
httputil.WriteRPCError(w, http.StatusServiceUnavailable,
|
||||
httputil.ErrCodeServiceUnavailable, pushNotConfiguredMessage)
|
||||
return
|
||||
}
|
||||
g.pushHandlers.CredentialsSummaryHandler(w, r)
|
||||
}
|
||||
|
||||
// pushCredentialsByProviderHandler dispatches GET / PUT / DELETE on
|
||||
// /v1/namespace/push-credentials/{provider} (feature #72 — generic
|
||||
// per-provider credential storage). The {provider} segment is parsed
|
||||
// inside the handler so unknown providers return a 400 with the list
|
||||
// of supported ones rather than a bare 404.
|
||||
func (g *Gateway) pushCredentialsByProviderHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if g.pushHandlers == nil {
|
||||
httputil.WriteRPCError(w, http.StatusServiceUnavailable,
|
||||
httputil.ErrCodeServiceUnavailable, pushNotConfiguredMessage)
|
||||
return
|
||||
}
|
||||
g.pushHandlers.CredentialsByProviderHandler(w, r)
|
||||
}
|
||||
|
||||
@ -144,6 +144,16 @@ func (g *Gateway) Routes() http.Handler {
|
||||
// instead of filing an ops ticket. Method dispatched in the handler.
|
||||
mux.HandleFunc("/v1/push/config", g.pushConfigHandler)
|
||||
|
||||
// Per-namespace, per-provider push credentials (feature #72 —
|
||||
// full-privacy push with APNs-direct + self-hosted ntfy). Generic by
|
||||
// design: any provider with a registered Validator plugs in here
|
||||
// without changes. Method + provider segment dispatched in the handler.
|
||||
//
|
||||
// Summary endpoint (no provider segment) returns "what's configured"
|
||||
// + "what's supported" in one round trip.
|
||||
mux.HandleFunc("/v1/namespace/push-credentials", g.pushCredentialsSummaryHandler)
|
||||
mux.HandleFunc("/v1/namespace/push-credentials/", g.pushCredentialsByProviderHandler)
|
||||
|
||||
// Per-namespace rate-limit configuration (feature #69).
|
||||
// GET / PUT / DELETE — tenants self-serve their gateway-level rate
|
||||
// limit override (requests_per_minute, burst) up to an operator-set
|
||||
|
||||
181
core/pkg/push/credentials/manager.go
Normal file
181
core/pkg/push/credentials/manager.go
Normal 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
|
||||
}
|
||||
288
core/pkg/push/credentials/manager_test.go
Normal file
288
core/pkg/push/credentials/manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
88
core/pkg/push/credentials/registry.go
Normal file
88
core/pkg/push/credentials/registry.go
Normal 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()
|
||||
}
|
||||
116
core/pkg/push/credentials/registry_test.go
Normal file
116
core/pkg/push/credentials/registry_test.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
161
core/pkg/push/credentials/store.go
Normal file
161
core/pkg/push/credentials/store.go
Normal 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
|
||||
}
|
||||
117
core/pkg/push/credentials/types.go
Normal file
117
core/pkg/push/credentials/types.go
Normal 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
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@ -38,13 +39,18 @@ import (
|
||||
// The factory is called once per fresh dispatcher build (cache miss).
|
||||
// Empty slice is allowed and means "this config produces no providers";
|
||||
// Manager treats that as ErrPushNotConfigured.
|
||||
type ProviderFactory func(cfg Config) []PushProvider
|
||||
//
|
||||
// The ctx is the request context that triggered the (cold-path)
|
||||
// dispatcher build. Factories that need to look up per-namespace
|
||||
// credentials from the credentials manager (e.g. APNs) should use it
|
||||
// so cancellation propagates correctly. ctx is never nil.
|
||||
type ProviderFactory func(ctx context.Context, cfg Config) []PushProvider
|
||||
|
||||
// ErrPushNotConfigured is returned by Send when the namespace has no
|
||||
// per-namespace config AND the gateway has no fallback defaults — i.e.
|
||||
// nothing to send through. Distinguish from ErrNoDevices (different
|
||||
// failure mode).
|
||||
var ErrPushNotConfigured = errors.New("push not configured for namespace; set ntfy_base_url or expo_access_token via PUT /v1/push/config")
|
||||
var ErrPushNotConfigured = errors.New("push not configured for namespace; set credentials via PUT /v1/namespace/push-credentials/{provider} or legacy /v1/push/config")
|
||||
|
||||
// Defaults are the gateway-YAML fallback when a namespace hasn't set its
|
||||
// own config. Any field set here applies to every namespace that doesn't
|
||||
@ -63,12 +69,27 @@ func (d Defaults) IsEmpty() bool {
|
||||
|
||||
// Manager is the top-level push entry point. Build with NewManager and
|
||||
// hand out via the gateway's dependencies. Safe for concurrent use.
|
||||
//
|
||||
// Cross-gateway invalidation: the per-namespace dispatcher is built
|
||||
// from BOTH the per-namespace push config (legacy 026) AND any
|
||||
// per-provider credentials (#72). If a tenant rotates an APNs p8 key
|
||||
// on gateway A, gateway B's CACHED dispatcher still holds an APNs
|
||||
// provider constructed from the OLD key — until either:
|
||||
//
|
||||
// 1. The dispatcher entry is evicted by LRU pressure (only when
|
||||
// activeCacheCap namespaces are also active), or
|
||||
// 2. The entry's TTL elapses (cacheEntryTTL, default 30s).
|
||||
//
|
||||
// The TTL is the defense-in-depth bound — same model as pkg/ratelimit.
|
||||
// Without it, low-traffic namespaces would never see rotated creds on
|
||||
// gateway B without an explicit broadcast layer.
|
||||
type Manager struct {
|
||||
store ConfigStore
|
||||
devices PushDeviceStore
|
||||
defaults Defaults
|
||||
factory ProviderFactory
|
||||
logger *zap.Logger
|
||||
ttl time.Duration // configurable for tests
|
||||
|
||||
// cache LRU of namespace → built dispatcher.
|
||||
mu sync.Mutex
|
||||
@ -81,6 +102,7 @@ type Manager struct {
|
||||
type cacheEntry struct {
|
||||
namespace string
|
||||
dispatcher *PushDispatcher
|
||||
builtAt time.Time
|
||||
}
|
||||
|
||||
// defaultCacheCap caps how many namespaces' dispatchers we hold in memory.
|
||||
@ -88,6 +110,12 @@ type cacheEntry struct {
|
||||
// memory under abuse.
|
||||
const defaultCacheCap = 256
|
||||
|
||||
// cacheEntryTTL bounds how long a stale dispatcher can serve before the
|
||||
// next dispatcherFor call rebuilds it from store + credentials. 30s
|
||||
// matches pkg/ratelimit and pkg/push/credentials so config + creds
|
||||
// changes propagate across the cluster within the same bounded window.
|
||||
const cacheEntryTTL = 30 * time.Second
|
||||
|
||||
// NewManager constructs a Manager with the given device store, config
|
||||
// store, fallback Defaults, and ProviderFactory.
|
||||
//
|
||||
@ -105,12 +133,26 @@ func NewManager(devices PushDeviceStore, store ConfigStore, defaults Defaults, f
|
||||
defaults: defaults,
|
||||
factory: factory,
|
||||
logger: logger,
|
||||
ttl: cacheEntryTTL,
|
||||
cache: make(map[string]*list.Element, defaultCacheCap),
|
||||
lru: list.New(),
|
||||
cacheCap: defaultCacheCap,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCacheTTL overrides the default dispatcher cache TTL. Intended
|
||||
// for tests (where 30s is too long) and for operators who want a
|
||||
// tighter cross-gateway propagation window. Non-positive values are
|
||||
// ignored.
|
||||
func (m *Manager) SetCacheTTL(d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ttl = d
|
||||
}
|
||||
|
||||
// SendToUser dispatches a push to every device registered for the user
|
||||
// in the given namespace. Looks up per-namespace config (or falls back
|
||||
// to defaults), builds the appropriate dispatcher, and sends.
|
||||
@ -165,15 +207,22 @@ func (m *Manager) Invalidate(namespace string) {
|
||||
}
|
||||
|
||||
// dispatcherFor returns a (cached or freshly built) dispatcher with the
|
||||
// providers configured for the given namespace.
|
||||
// providers configured for the given namespace. Entries older than
|
||||
// `ttl` are evicted on access and rebuilt — this bounds the staleness
|
||||
// of credential changes that happened on another gateway.
|
||||
func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDispatcher, error) {
|
||||
// Fast path — already cached.
|
||||
// Fast path — already cached AND not expired.
|
||||
m.mu.Lock()
|
||||
if elem, ok := m.cache[namespace]; ok {
|
||||
m.lru.MoveToFront(elem)
|
||||
entry := elem.Value.(*cacheEntry)
|
||||
m.mu.Unlock()
|
||||
return entry.dispatcher, nil
|
||||
if time.Since(entry.builtAt) < m.ttl {
|
||||
m.lru.MoveToFront(elem)
|
||||
m.mu.Unlock()
|
||||
return entry.dispatcher, nil
|
||||
}
|
||||
// Expired — drop the stale entry and fall through to rebuild.
|
||||
m.lru.Remove(elem)
|
||||
delete(m.cache, namespace)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
@ -186,10 +235,16 @@ func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDis
|
||||
// Insert into cache (eviction if at capacity).
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// Recheck under lock — another goroutine may have built one.
|
||||
// Recheck under lock — another goroutine may have built one. Use it
|
||||
// only if it's still fresh; otherwise our newly-built one replaces.
|
||||
if elem, ok := m.cache[namespace]; ok {
|
||||
m.lru.MoveToFront(elem)
|
||||
return elem.Value.(*cacheEntry).dispatcher, nil
|
||||
entry := elem.Value.(*cacheEntry)
|
||||
if time.Since(entry.builtAt) < m.ttl {
|
||||
m.lru.MoveToFront(elem)
|
||||
return entry.dispatcher, nil
|
||||
}
|
||||
m.lru.Remove(elem)
|
||||
delete(m.cache, namespace)
|
||||
}
|
||||
if m.lru.Len() >= m.cacheCap {
|
||||
oldest := m.lru.Back()
|
||||
@ -199,7 +254,7 @@ func (m *Manager) dispatcherFor(ctx context.Context, namespace string) (*PushDis
|
||||
delete(m.cache, old.namespace)
|
||||
}
|
||||
}
|
||||
entry := &cacheEntry{namespace: namespace, dispatcher: d}
|
||||
entry := &cacheEntry{namespace: namespace, dispatcher: d, builtAt: time.Now()}
|
||||
m.cache[namespace] = m.lru.PushFront(entry)
|
||||
return d, nil
|
||||
}
|
||||
@ -241,18 +296,19 @@ func (m *Manager) buildDispatcher(ctx context.Context, namespace string) (*PushD
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse to build a dispatcher with no providers — caller gets a
|
||||
// clear error instead of a silent no-op.
|
||||
if eff.NtfyBaseURL == "" && eff.ExpoAccessToken == "" {
|
||||
return nil, ErrPushNotConfigured
|
||||
}
|
||||
if m.factory == nil {
|
||||
// Defensive: a Manager built without a factory can't produce
|
||||
// providers. Programmer error; surface explicitly.
|
||||
return nil, fmt.Errorf("manager: no provider factory configured")
|
||||
}
|
||||
|
||||
providers := m.factory(eff)
|
||||
// Authoritative provider-presence check is at the factory output —
|
||||
// not at the resolved flat-field config — because providers can
|
||||
// also be sourced from the per-namespace credentials store
|
||||
// (feature #72: APNs is fully credentialed and has no flat field
|
||||
// here). The factory returns an empty slice when nothing is
|
||||
// configured, which we translate to ErrPushNotConfigured.
|
||||
providers := m.factory(ctx, eff)
|
||||
if len(providers) == 0 {
|
||||
return nil, ErrPushNotConfigured
|
||||
}
|
||||
|
||||
@ -77,7 +77,7 @@ func TestManager_namespace_with_no_config_uses_defaults(t *testing.T) {
|
||||
defaults := Defaults{NtfyBaseURL: "http://default-ntfy"}
|
||||
|
||||
var providerCalls atomic.Int32
|
||||
factory := func(c Config) []PushProvider {
|
||||
factory := func(_ context.Context, c Config) []PushProvider {
|
||||
providerCalls.Add(1)
|
||||
// Verify the manager passed defaults through to the factory.
|
||||
if c.NtfyBaseURL != "http://default-ntfy" {
|
||||
@ -108,7 +108,7 @@ func TestManager_namespace_config_overrides_defaults(t *testing.T) {
|
||||
defaults := Defaults{NtfyBaseURL: "http://default-ntfy"}
|
||||
|
||||
var seenURL string
|
||||
factory := func(c Config) []PushProvider {
|
||||
factory := func(_ context.Context, c Config) []PushProvider {
|
||||
seenURL = c.NtfyBaseURL
|
||||
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
|
||||
}
|
||||
@ -124,7 +124,7 @@ func TestManager_namespace_config_overrides_defaults(t *testing.T) {
|
||||
|
||||
func TestManager_no_config_no_defaults_returns_ErrPushNotConfigured(t *testing.T) {
|
||||
store := newFakeConfigStore()
|
||||
factory := func(_ Config) []PushProvider { return nil }
|
||||
factory := func(_ context.Context, _ Config) []PushProvider { return nil }
|
||||
|
||||
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
|
||||
_, err := m.dispatcherFor(context.Background(), "ns-A")
|
||||
@ -138,7 +138,7 @@ func TestManager_caches_dispatchers_per_namespace(t *testing.T) {
|
||||
store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "u"})
|
||||
|
||||
var factoryCalls atomic.Int32
|
||||
factory := func(_ Config) []PushProvider {
|
||||
factory := func(_ context.Context, _ Config) []PushProvider {
|
||||
factoryCalls.Add(1)
|
||||
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
|
||||
}
|
||||
@ -160,7 +160,7 @@ func TestManager_invalidate_forces_rebuild(t *testing.T) {
|
||||
store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "v1"})
|
||||
|
||||
var seenURLs []string
|
||||
factory := func(c Config) []PushProvider {
|
||||
factory := func(_ context.Context, c Config) []PushProvider {
|
||||
seenURLs = append(seenURLs, c.NtfyBaseURL)
|
||||
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
|
||||
}
|
||||
@ -190,7 +190,7 @@ func TestManager_per_namespace_isolation(t *testing.T) {
|
||||
|
||||
urlByNS := make(map[string]string)
|
||||
var mu sync.Mutex
|
||||
factory := func(c Config) []PushProvider {
|
||||
factory := func(_ context.Context, c Config) []PushProvider {
|
||||
mu.Lock()
|
||||
urlByNS[c.Namespace] = c.NtfyBaseURL
|
||||
mu.Unlock()
|
||||
@ -238,7 +238,7 @@ func TestManager_concurrent_dispatcherFor_no_race(t *testing.T) {
|
||||
// Run with -race.
|
||||
store := newFakeConfigStore()
|
||||
store.Upsert(context.Background(), Config{Namespace: "ns", NtfyBaseURL: "u"})
|
||||
factory := func(_ Config) []PushProvider { return []PushProvider{&managerFakeProvider{name: "ntfy"}} }
|
||||
factory := func(_ context.Context, _ Config) []PushProvider { return []PushProvider{&managerFakeProvider{name: "ntfy"}} }
|
||||
|
||||
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
|
||||
|
||||
|
||||
180
core/pkg/push/providers/apns/apns.go
Normal file
180
core/pkg/push/providers/apns/apns.go
Normal 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)
|
||||
}
|
||||
372
core/pkg/push/providers/apns/apns_test.go
Normal file
372
core/pkg/push/providers/apns/apns_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
150
core/pkg/push/providers/apns/credentials.go
Normal file
150
core/pkg/push/providers/apns/credentials.go
Normal 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
|
||||
}
|
||||
149
core/pkg/push/providers/ntfy/credentials.go
Normal file
149
core/pkg/push/providers/ntfy/credentials.go
Normal 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
|
||||
}
|
||||
112
core/pkg/push/providers/ntfy/credentials_test.go
Normal file
112
core/pkg/push/providers/ntfy/credentials_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@debros/orama",
|
||||
"version": "0.122.13",
|
||||
"version": "0.122.14",
|
||||
"description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user